Files
CapaKraken/packages/engine/src/sah/calculator.ts
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

145 lines
4.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};
}