/** * Standard Available Hours (SAH) calculator. * * SAH = net working time after deducting holidays and absences. * It is the denominator for chargeability calculations. */ import type { SpainScheduleRule } from "@capakraken/shared"; // ─── Types ────────────────────────────────────────────────────────────────── export interface SAHInput { /** Base daily working hours for the country (e.g. 8 for DE, 9 for IN). */ dailyWorkingHours: number; /** Optional variable schedule rules (e.g. Spain). */ scheduleRules?: SpainScheduleRule | null; /** Resource FTE factor (0.01 – 1.0). Reduces effective daily hours. */ fte: number; /** Period start date (inclusive). */ periodStart: Date; /** Period end date (inclusive). */ periodEnd: Date; /** Public holiday dates within the period (ISO strings or Dates). */ publicHolidays: (Date | string)[]; /** Absence dates within the period (vacation, illness, other). */ absenceDays: (Date | string)[]; } export interface SAHResult { /** Total calendar days in the period. */ calendarDays: number; /** Weekend days in the period. */ weekendDays: number; /** Working days (calendar - weekends). */ grossWorkingDays: number; /** Public holidays falling on working days. */ publicHolidayDays: number; /** Absence days falling on working days (excluding holidays). */ absenceDays: number; /** Net working days after holidays and absences. */ netWorkingDays: number; /** Average effective hours per working day (after FTE scaling). */ effectiveHoursPerDay: number; /** Total Standard Available Hours for the period. */ standardAvailableHours: number; } // ─── Helpers ──────────────────────────────────────────────────────────────── function toISODate(d: Date | string): string { if (typeof d === "string") return d.slice(0, 10); return d.toISOString().slice(0, 10); } function isWeekend(date: Date): boolean { const day = date.getUTCDay(); return day === 0 || day === 6; } /** * Get daily working hours for a specific date given optional Spain schedule rules. */ export function getDailyHours( date: Date, baseHours: number, scheduleRules?: SpainScheduleRule | null, ): number { if (!scheduleRules || scheduleRules.type !== "spain") { return baseHours; } const dayOfWeek = date.getUTCDay(); // Fridays always use fridayHours if (dayOfWeek === 5) { return scheduleRules.fridayHours; } // Check if date falls in summer period const month = date.getUTCMonth() + 1; const day = date.getUTCDate(); const mmdd = `${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`; if (mmdd >= scheduleRules.summerPeriod.from && mmdd <= scheduleRules.summerPeriod.to) { return scheduleRules.summerHours; } return scheduleRules.regularHours; } // ─── Calculator ───────────────────────────────────────────────────────────── export function calculateSAH(input: SAHInput): SAHResult { const { dailyWorkingHours, scheduleRules, fte, periodStart, periodEnd, publicHolidays, absenceDays } = input; const holidaySet = new Set(publicHolidays.map(toISODate)); const absenceSet = new Set(absenceDays.map(toISODate)); let calendarDays = 0; let weekendDays = 0; let publicHolidayCount = 0; let absenceCount = 0; let totalHoursOnWorkingDays = 0; let netWorkingDays = 0; 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) { calendarDays++; const iso = cursor.toISOString().slice(0, 10); if (isWeekend(cursor)) { weekendDays++; } else if (holidaySet.has(iso)) { publicHolidayCount++; } else if (absenceSet.has(iso)) { absenceCount++; } else { // This is a net working day const hoursForDay = getDailyHours(cursor, dailyWorkingHours, scheduleRules); totalHoursOnWorkingDays += hoursForDay * fte; netWorkingDays++; } cursor.setUTCDate(cursor.getUTCDate() + 1); } const grossWorkingDays = calendarDays - weekendDays; const effectiveHoursPerDay = netWorkingDays > 0 ? totalHoursOnWorkingDays / netWorkingDays : dailyWorkingHours * fte; return { calendarDays, weekendDays, grossWorkingDays, publicHolidayDays: publicHolidayCount, absenceDays: absenceCount, netWorkingDays, effectiveHoursPerDay: Math.round(effectiveHoursPerDay * 100) / 100, standardAvailableHours: Math.round(totalHoursOnWorkingDays * 100) / 100, }; }