import { VacationStatus } from "@capakraken/db"; import { getPublicHolidays, toIsoDate, MILLISECONDS_PER_DAY, DAY_KEYS, normalizeCityName, normalizeStateCode, type WeekdayAvailability } from "@capakraken/shared"; type CalendarScope = "COUNTRY" | "STATE" | "CITY"; type HolidayCalendarEntryRecord = { date: Date; isRecurringAnnual: boolean; }; type HolidayCalendarRecord = { entries: HolidayCalendarEntryRecord[]; }; type VacationRecord = { resourceId: string; startDate: Date; endDate: Date; type: string; isHalfDay: boolean; }; export type ResourceCapacityProfile = { id: string; availability: WeekdayAvailability; countryId: string | null | undefined; countryCode: string | null | undefined; federalState: string | null | undefined; metroCityId: string | null | undefined; metroCityName: string | null | undefined; }; export type ResourceDailyAvailabilityContext = { absenceFractionsByDate: Map; holidayDates: Set; vacationFractionsByDate: Map; }; type ResourceCapacityDbClient = { holidayCalendar?: { findMany: (args: { where: Record; include: { entries: true }; orderBy: Array>; }) => Promise; }; vacation?: { findMany: (args: { where: Record; select: Record>; }) => Promise; }; }; const CITY_HOLIDAY_RULES: Array<{ countryCode: string; cityName: string; resolveDates: (year: number) => string[]; }> = [ { countryCode: "DE", cityName: "Augsburg", resolveDates: (year) => [`${year}-08-08`], }, ]; export function getAvailabilityHoursForDate( availability: WeekdayAvailability, date: Date, ): number { const key = DAY_KEYS[date.getUTCDay()]; return key ? (availability[key] ?? 0) : 0; } function listBuiltinHolidayDates(input: { periodStart: Date; periodEnd: Date; countryCode: string | null | undefined; federalState: string | null | undefined; metroCityName: string | null | undefined; }): Set { const dates = new Set(); const startIso = toIsoDate(input.periodStart); const endIso = toIsoDate(input.periodEnd); const startYear = input.periodStart.getUTCFullYear(); const endYear = input.periodEnd.getUTCFullYear(); if (input.countryCode === "DE") { for (let year = startYear; year <= endYear; year += 1) { for (const holiday of getPublicHolidays(year, input.federalState ?? undefined)) { if (holiday.date >= startIso && holiday.date <= endIso) { dates.add(holiday.date); } } } } const normalizedCityName = normalizeCityName(input.metroCityName); if (input.countryCode && normalizedCityName) { for (const rule of CITY_HOLIDAY_RULES) { if ( rule.countryCode === input.countryCode && normalizeCityName(rule.cityName) === normalizedCityName ) { for (let year = startYear; year <= endYear; year += 1) { for (const date of rule.resolveDates(year)) { if (date >= startIso && date <= endIso) { dates.add(date); } } } } } } return dates; } function resolveCalendarEntryDates( calendars: HolidayCalendarRecord[], periodStart: Date, periodEnd: Date, ): Set { const dates = new Set(); const startIso = toIsoDate(periodStart); const endIso = toIsoDate(periodEnd); const startYear = periodStart.getUTCFullYear(); const endYear = periodEnd.getUTCFullYear(); 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 isoDate = toIsoDate(effectiveDate); if (isoDate >= startIso && isoDate <= endIso) { dates.add(isoDate); } if (!entry.isRecurringAnnual) { break; } } } } return dates; } async function loadCustomHolidayDates( db: ResourceCapacityDbClient, input: { periodStart: Date; periodEnd: Date; countryId: string | null | undefined; federalState: string | null | undefined; metroCityId: string | null | undefined; }, ): Promise> { if (!input.countryId || typeof db.holidayCalendar?.findMany !== "function") { return new Set(); } const stateCode = normalizeStateCode(input.federalState); const metroCityId = input.metroCityId?.trim() || null; const calendars = await db.holidayCalendar.findMany({ where: { isActive: true, countryId: input.countryId, OR: [ { scopeType: "COUNTRY" as CalendarScope }, ...(stateCode ? [{ scopeType: "STATE" as CalendarScope, stateCode }] : []), ...(metroCityId ? [{ scopeType: "CITY" as CalendarScope, metroCityId }] : []), ], }, include: { entries: true }, orderBy: [{ priority: "asc" }, { createdAt: "asc" }], }); return resolveCalendarEntryDates( calendars as HolidayCalendarRecord[], input.periodStart, input.periodEnd, ); } function buildProfileKey(profile: ResourceCapacityProfile): string { return JSON.stringify({ countryId: profile.countryId ?? null, countryCode: profile.countryCode ?? null, federalState: profile.federalState ?? null, metroCityId: profile.metroCityId ?? null, metroCityName: profile.metroCityName ?? null, }); } export async function loadResourceDailyAvailabilityContexts( db: ResourceCapacityDbClient, resources: ResourceCapacityProfile[], periodStart: Date, periodEnd: Date, ): Promise> { const profileHolidayCache = new Map>>(); const resourceIds = resources.map((resource) => resource.id); const vacations = resourceIds.length > 0 && typeof db.vacation?.findMany === "function" ? await db.vacation.findMany({ where: { resourceId: { in: resourceIds }, status: VacationStatus.APPROVED, startDate: { lte: periodEnd }, endDate: { gte: periodStart }, }, select: { resourceId: true, startDate: true, endDate: true, type: true, isHalfDay: true, }, }) : []; const vacationsByResourceId = new Map(); for (const vacation of vacations as VacationRecord[]) { const items = vacationsByResourceId.get(vacation.resourceId) ?? []; items.push(vacation); vacationsByResourceId.set(vacation.resourceId, items); } const contexts = new Map(); for (const resource of resources) { const profileKey = buildProfileKey(resource); const holidayPromise = profileHolidayCache.get(profileKey) ?? (async () => { const builtin = listBuiltinHolidayDates({ periodStart, periodEnd, countryCode: resource.countryCode, federalState: resource.federalState, metroCityName: resource.metroCityName, }); const custom = await loadCustomHolidayDates(db, { periodStart, periodEnd, countryId: resource.countryId, federalState: resource.federalState, metroCityId: resource.metroCityId, }); return new Set([...builtin, ...custom]); })(); if (!profileHolidayCache.has(profileKey)) { profileHolidayCache.set(profileKey, holidayPromise); } const holidayDates = new Set(await holidayPromise); const absenceFractionsByDate = new Map(); const vacationFractionsByDate = new Map(); const resourceVacations = vacationsByResourceId.get(resource.id) ?? []; for (const vacation of resourceVacations) { const overlapStart = new Date(Math.max(vacation.startDate.getTime(), periodStart.getTime())); const overlapEnd = new Date(Math.min(vacation.endDate.getTime(), 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); const fraction = vacation.isHalfDay ? 0.5 : 1; if (vacation.type === "PUBLIC_HOLIDAY") { holidayDates.add(isoDate); } if (vacation.type !== "PUBLIC_HOLIDAY") { const existingVacation = vacationFractionsByDate.get(isoDate) ?? 0; vacationFractionsByDate.set(isoDate, Math.max(existingVacation, fraction)); } const existing = absenceFractionsByDate.get(isoDate) ?? 0; if (vacation.type === "PUBLIC_HOLIDAY" || !holidayDates.has(isoDate)) { absenceFractionsByDate.set(isoDate, Math.max(existing, fraction)); } cursor.setUTCDate(cursor.getUTCDate() + 1); } } for (const isoDate of holidayDates) { const existing = absenceFractionsByDate.get(isoDate) ?? 0; absenceFractionsByDate.set(isoDate, Math.max(existing, 1)); } contexts.set(resource.id, { absenceFractionsByDate, holidayDates, vacationFractionsByDate, }); } return contexts; } function calculateDayAvailabilityFraction( context: ResourceDailyAvailabilityContext | undefined, isoDate: string, ): number { const fraction = context?.absenceFractionsByDate.get(isoDate) ?? 0; return Math.max(0, 1 - fraction); } export function calculateEffectiveDayAvailability(input: { availability: WeekdayAvailability; date: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { const baseHours = getAvailabilityHoursForDate(input.availability, input.date); if (baseHours <= 0) { return 0; } return baseHours * calculateDayAvailabilityFraction(input.context, toIsoDate(input.date)); } export function calculateEffectiveAvailableHours(input: { availability: WeekdayAvailability; periodStart: Date; periodEnd: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { let hours = 0; const cursor = new Date(input.periodStart); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(input.periodEnd); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { hours += calculateEffectiveDayAvailability({ availability: input.availability, date: cursor, context: input.context, }); cursor.setUTCDate(cursor.getUTCDate() + 1); } return hours; } export function countEffectiveWorkingDays(input: { availability: WeekdayAvailability; periodStart: Date; periodEnd: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { let days = 0; const cursor = new Date(input.periodStart); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(input.periodEnd); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { if (calculateEffectiveDayAvailability({ availability: input.availability, date: cursor, context: input.context, }) > 0) { days += 1; } cursor.setUTCDate(cursor.getUTCDate() + 1); } return days; } export function calculateEffectiveBookedHours(input: { availability: WeekdayAvailability; startDate: Date; endDate: Date; hoursPerDay: number; periodStart: Date; periodEnd: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { const overlapStart = new Date(Math.max(input.startDate.getTime(), input.periodStart.getTime())); const overlapEnd = new Date(Math.min(input.endDate.getTime(), input.periodEnd.getTime())); if (overlapStart > overlapEnd) { return 0; } let hours = 0; 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 dayBaseHours = getAvailabilityHoursForDate(input.availability, cursor); if (dayBaseHours > 0) { hours += input.hoursPerDay * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor)); } cursor.setUTCDate(cursor.getUTCDate() + 1); } return hours; } export function calculateEffectiveAllocationHours(input: { availability: WeekdayAvailability; startDate: Date; endDate: Date; hoursPerDay: number; periodStart: Date; periodEnd: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { return calculateEffectiveBookedHours(input); } export function calculateEffectiveAllocationCostCents(input: { availability: WeekdayAvailability; startDate: Date; endDate: Date; dailyCostCents: number; periodStart: Date; periodEnd: Date; context: ResourceDailyAvailabilityContext | undefined; }): number { let costCents = 0; const overlapStart = new Date( Math.max(input.startDate.getTime(), input.periodStart.getTime()), ); const overlapEnd = new Date( Math.min(input.endDate.getTime(), input.periodEnd.getTime()), ); if (overlapStart > overlapEnd) { return 0; } 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 baseHours = getAvailabilityHoursForDate(input.availability, cursor); if (baseHours > 0) { costCents += input.dailyCostCents * calculateDayAvailabilityFraction(input.context, toIsoDate(cursor)); } cursor.setUTCDate(cursor.getUTCDate() + 1); } return Math.round(costCents); } export function enumerateIsoDates( periodStart: Date, periodEnd: Date, ): string[] { const dates: string[] = []; const cursor = new Date(periodStart); cursor.setUTCHours(0, 0, 0, 0); const end = new Date(periodEnd); end.setUTCHours(0, 0, 0, 0); while (cursor <= end) { dates.push(toIsoDate(cursor)); cursor.setTime(cursor.getTime() + MILLISECONDS_PER_DAY); } return dates; }