From bc1d92937c5bcdf15c287d995dab0ff74fe5b29d Mon Sep 17 00:00:00 2001 From: Kenearos Date: Tue, 12 May 2026 00:15:38 +0200 Subject: [PATCH] refactor: BonusCalculator runs all 3 variants and picks winner --- calculator.js | 212 +++++++++++++++++--------------------------------- test-suite.js | 100 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 142 deletions(-) diff --git a/calculator.js b/calculator.js index a3edb2e..19b0e37 100644 --- a/calculator.js +++ b/calculator.js @@ -1,41 +1,26 @@ /** - * Duty Schedule Bonus Calculator - * Calculates bonuses based on weekend and holiday duty shifts + * Bonus Calculator (NRW Psychiatrie 2011) + * Orchestrator: classifies duties, runs all three variants (V1/V2/V3), picks the winner. + * Pure variant logic lives in variants.js. */ class BonusCalculator { constructor(holidayProvider) { this.holidayProvider = holidayProvider; - this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday) - this.RATE_WEEKEND = 450; // Weekend/holiday rate - this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus - this.DEDUCTION_AMOUNT = 2.0; // Deduction after reaching threshold + this.RATE_NORMAL = 250; + this.RATE_WEEKEND = 450; } /** - * Check if a date is a qualifying day (weekend or holiday related) - * Qualifying days: Friday, Saturday, Sunday, Public Holiday, Day before public holiday - * @param {Date} date - * @returns {boolean} + * Whether the given date is a "qualifying" day (used by UI for badge coloring). + * Mirrors the old isQualifyingDay so app.js does not break. */ isQualifyingDay(date) { - const dayOfWeek = date.getDay(); // 0 = Sunday, 5 = Friday, 6 = Saturday - - // Weekend: Friday (5), Saturday (6), Sunday (0) - const isWeekend = dayOfWeek === 5 || dayOfWeek === 6 || dayOfWeek === 0; - - // Public holiday - const isHoliday = this.holidayProvider.isHoliday(date); - - // Day before public holiday - const isDayBeforeHoliday = this.holidayProvider.isDayBeforeHoliday(date); - - return isWeekend || isHoliday || isDayBeforeHoliday; + const slot = classify(date, this.holidayProvider); + return slot !== 'weekday'; } /** - * Get day type label for display - * @param {Date} date - * @returns {string} + * Human-readable label for the date's day type (used by UI). */ getDayTypeLabel(date) { const dayOfWeek = date.getDay(); @@ -43,12 +28,8 @@ class BonusCalculator { const holidayName = this.holidayProvider.getHolidayName(date); const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date); - if (isHoliday) { - return `Feiertag (${holidayName})`; - } - if (isDayBefore) { - return 'Tag vor Feiertag'; - } + if (isHoliday) return `Feiertag (${holidayName})`; + if (isDayBefore) return 'Tag vor Feiertag'; if (dayOfWeek === 5) return 'Freitag'; if (dayOfWeek === 6) return 'Samstag'; if (dayOfWeek === 0) return 'Sonntag'; @@ -58,143 +39,90 @@ class BonusCalculator { } /** - * Calculate bonus for a single employee for a given month - * @param {Array} duties - Array of duty objects: {date: Date, share: number (1.0 or 0.5)} - * @returns {Object} Calculation result + * Build the dutyDetails array (date, share, isQualifying, dayType) for the UI. */ - calculateMonthlyBonus(duties) { + buildDutyDetails(duties) { + return duties.map(duty => ({ + date: duty.date, + share: duty.share, + isQualifying: this.isQualifyingDay(duty.date), + dayType: this.getDayTypeLabel(duty.date) + })); + } + + /** + * Calculate the bonus for a single employee for a given month. + * @param {Array} duties - Array of { date: Date, share: number } + * @param {boolean} isVacation - Vacation toggle (halves thresholds + deductions) + * @returns {Object} new-shape result (winner, allResults, totalBonus, classified, isVacation, dutyDetails) + */ + calculateMonthlyBonus(duties, isVacation = false) { if (!duties || duties.length === 0) { - return this.getEmptyResult(); + return this.getEmptyResult(isVacation); } - // Separate qualifying days (Friday separate from others) and non-qualifying days - let qualifyingDaysFriday = 0; - let qualifyingDaysOther = 0; - let normalDays = 0; - const dutyDetails = []; + const classified = classifyDuties(duties, this.holidayProvider); + const v1 = variant1(classified, isVacation); + const v2 = variant2(classified, isVacation); + const v3 = variant3(classified, isVacation); + const results = [v1, v2, v3]; - duties.forEach(duty => { - const isQualifying = this.isQualifyingDay(duty.date); - const dayType = this.getDayTypeLabel(duty.date); - const isFriday = duty.date.getDay() === 5; - - if (isQualifying) { - if (isFriday) { - qualifyingDaysFriday += duty.share; - } else { - qualifyingDaysOther += duty.share; - } - } else { - normalDays += duty.share; + // Pick winner: highest bonus, tie-breaker = lowest variantId (strict >) + let winner = results[0]; + for (let i = 1; i < results.length; i++) { + if (results[i].bonus > winner.bonus) { + winner = results[i]; } - - dutyDetails.push({ - date: duty.date, - share: duty.share, - isQualifying: isQualifying, - dayType: dayType - }); - }); - - const qualifyingDaysTotal = qualifyingDaysFriday + qualifyingDaysOther; - - // Check if threshold is reached - const thresholdReached = qualifyingDaysTotal >= this.MIN_QUALIFYING_DAYS; - - let bonus = 0; - let normalDaysPaid = 0; - let qualifyingDaysPaid = 0; - let deductionFromFriday = 0; - let deductionFromOther = 0; - let totalDeduction = 0; - - if (thresholdReached) { - // Deduct qualifying days with Friday priority - totalDeduction = this.DEDUCTION_AMOUNT; - - // First deduct from Friday - deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday); - - // Remaining deduction from other qualifying days - deductionFromOther = Math.max(0, totalDeduction - deductionFromFriday); - - // Calculate paid days - const qualifyingDaysFridayPaid = Math.max(0, qualifyingDaysFriday - deductionFromFriday); - const qualifyingDaysOtherPaid = Math.max(0, qualifyingDaysOther - deductionFromOther); - - qualifyingDaysPaid = qualifyingDaysFridayPaid + qualifyingDaysOtherPaid; - normalDaysPaid = normalDays; - - // Calculate bonus - bonus = (normalDaysPaid * this.RATE_NORMAL) + (qualifyingDaysPaid * this.RATE_WEEKEND); } - // If threshold not reached: no bonus paid (neither WT nor WE) + winner.isWinner = true; return { + classified, + isVacation, + winner, + allResults: results, + totalBonus: winner.bonus, totalDuties: duties.length, - totalDaysWorked: qualifyingDaysTotal + normalDays, - normalDays: normalDays, - qualifyingDaysFriday: qualifyingDaysFriday, - qualifyingDaysOther: qualifyingDaysOther, - qualifyingDays: qualifyingDaysTotal, - thresholdReached: thresholdReached, - deductionFromFriday: deductionFromFriday, - deductionFromOther: deductionFromOther, - qualifyingDaysDeducted: totalDeduction, - normalDaysPaid: normalDaysPaid, - qualifyingDaysPaid: qualifyingDaysPaid, - bonusNormalDays: normalDaysPaid * this.RATE_NORMAL, - bonusQualifyingDays: qualifyingDaysPaid * this.RATE_WEEKEND, - totalBonus: bonus, - dutyDetails: dutyDetails + dutyDetails: this.buildDutyDetails(duties) }; } /** - * Calculate bonuses for all employees - * @param {Object} employeeDuties - Object with employee names as keys and duty arrays as values - * @returns {Object} Results for all employees + * Calculate for all employees. vacationMap: { [employeeName]: boolean } */ - calculateAllEmployees(employeeDuties) { + calculateAllEmployees(employeeDuties, vacationMap = {}) { const results = {}; - - for (const [employeeName, duties] of Object.entries(employeeDuties)) { - results[employeeName] = this.calculateMonthlyBonus(duties); + for (const [name, duties] of Object.entries(employeeDuties)) { + const isVac = Boolean(vacationMap[name]); + results[name] = this.calculateMonthlyBonus(duties, isVac); } - return results; } - /** - * Get empty result structure - * @returns {Object} - */ - getEmptyResult() { + getEmptyResult(isVacation = false) { + const empty = { + variantId: 1, + eligible: false, + threshold: null, + deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, + paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, + bonus: 0, + isWinner: true + }; return { - totalDuties: 0, - totalDaysWorked: 0, - normalDays: 0, - qualifyingDaysFriday: 0, - qualifyingDaysOther: 0, - qualifyingDays: 0, - thresholdReached: false, - deductionFromFriday: 0, - deductionFromOther: 0, - qualifyingDaysDeducted: 0, - normalDaysPaid: 0, - qualifyingDaysPaid: 0, - bonusNormalDays: 0, - bonusQualifyingDays: 0, + classified: { fr: 0, sa: 0, so: 0, weekday: 0 }, + isVacation, + winner: empty, + allResults: [empty, + { ...empty, variantId: 2, isWinner: false }, + { ...empty, variantId: 3, isWinner: false } + ], totalBonus: 0, + totalDuties: 0, dutyDetails: [] }; } - /** - * Format currency for display - * @param {number} amount - * @returns {string} - */ formatCurrency(amount) { return new Intl.NumberFormat('de-DE', { style: 'currency', diff --git a/test-suite.js b/test-suite.js index b31834f..4a663e7 100644 --- a/test-suite.js +++ b/test-suite.js @@ -793,6 +793,106 @@ runner.test('variant2: threshold-Shape normal {sa:1, weekday:2}', (t) => { t.assertEqual(r.threshold.weekday, 2, 'threshold.weekday=2'); }); +// ============================================================================ +// BonusCalculator - Winner Selection (new shape) +// ============================================================================ + +runner.test('Winner: klarer Sieger mit weekdays + 1 Fr', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di + { date: new Date('2025-11-26T12:00:00'), share: 1.0 }, // Mi + { date: new Date('2025-11-27T12:00:00'), share: 1.0 }, // Do + { date: new Date('2025-11-04T12:00:00'), share: 1.0 } // Di + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertTrue(result.winner.isWinner, 'winner.isWinner=true'); + t.assertEqual(result.allResults.length, 3, '3 Varianten im allResults'); + t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); +}); + +runner.test('Winner: klarer V3-Sieger (nur WE-Dienste)', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa + { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // So + { date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa + ]; + const result = calc.calculateMonthlyBonus(duties, false); + // V1: fr+so=1, weekday=0 -> not eligible + // V2: sa=2, weekday=0 -> not eligible + // V3: pool=3 -> eligible, deduction 2 (fr=0,so=1 abgezogen, sa=1 abgezogen) -> 1 sa paid -> 450 + t.assertEqual(result.winner.variantId, 3, 'V3 muss Sieger sein'); + t.assertEqual(result.totalBonus, 450, 'bonus=450'); +}); + +runner.test('Winner: Tie-Breaker - alle three not eligible -> V1 nominal winner, totalBonus 0', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // fr=1, sa=0, so=0, weekday=3: + // V1: fr+so=1 ok, weekday=3 ok -> eligible. Abzug fr=1, weekday=3 -> alles weg, bonus 0. + // V2: sa=0 -> not eligible (0). + // V3: pool=1 < 2 -> not eligible (0). + // -> tie at 0; V1 has eligible=true so its result is still 0. Strict > keeps v1 as winner. + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di + { date: new Date('2025-11-26T12:00:00'), share: 1.0 } // Mi + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertEqual(result.winner.variantId, 1, 'V1 wins tie (lowest variantId)'); + t.assertEqual(result.totalBonus, 0, 'totalBonus=0 (all-zero tie)'); +}); + +runner.test('Winner: nur V3 produziert positive bonus -> V3 winner', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // Three Saturdays: V1 not eligible, V2 not eligible (weekday=0), V3 eligible with positive bonus. + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-29T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-15T12:00:00'), share: 1.0 } + ]; + const result = calc.calculateMonthlyBonus(duties, false); + // V3: pool=3, abzug 2 (so=0, fr=0, sa=2 abgezogen) -> 1 sa paid -> 450 + t.assertEqual(result.winner.variantId, 3, 'V3 winner'); + t.assertEqual(result.totalBonus, 450, 'bonus=450'); +}); + +runner.test('Winner: result-Shape enthaelt classified, isVacation, dutyDetails', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + const duties = [ + { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, + { date: new Date('2025-11-23T12:00:00'), share: 1.0 } + ]; + const result = calc.calculateMonthlyBonus(duties, false); + t.assertTrue('classified' in result, 'classified field exists'); + t.assertTrue('isVacation' in result, 'isVacation field exists'); + t.assertTrue('dutyDetails' in result, 'dutyDetails field exists'); + t.assertEqual(result.dutyDetails.length, 2, 'dutyDetails has 2 entries'); + t.assertEqual(result.isVacation, false, 'isVacation=false'); +}); + +runner.test('Winner: Urlaubsmodus halbiert alle Schwellen', (t) => { + const hp = new HolidayProvider(); + const calc = new BonusCalculator(hp); + // fr=0.5, weekday=1.5 -> V1 eligible im Urlaub (0.5 >= 0.5, 1.5 >= 1.5) + const duties = [ + { date: new Date('2025-11-21T12:00:00'), share: 0.5 }, // Fr + { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo + { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di + ]; + const result = calc.calculateMonthlyBonus(duties, true); + t.assertEqual(result.isVacation, true, 'isVacation propagated'); + t.assertEqual(result.winner.variantId, 1, 'V1 wins under vacation'); +}); + // ============================================================================ // Display Functions // ============================================================================