# Feature B: Bonus-Varianten + Urlaubsmodus + Date-Stepper Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the current single-rule bonus calculation with three configurable variants (V1, V2, V3 loose), auto-selecting the highest-bonus variant. Add a per-employee-per-month vacation toggle that halves variant thresholds. Add a small date-stepper UX to the duty entry tab. **Architecture:** Strategy-pattern split: `variants.js` holds three pure variant functions and the day classification helper; `calculator.js` becomes the public API that runs all variants and picks the winner. `storage.js` grows two methods for the new `dienstplan_vacation` localStorage key. UI changes in `app.js` add a vacation checkbox per employee in the calculation tab and a collapsible variant breakdown in the result card. **Tech Stack:** Vanilla ES6+ classes (no build step), browser localStorage, the existing in-repo test runner (`test-suite.js`). --- ## How to run tests This project has no Node test runner. Tests live in `test-suite.js` and run in the browser via `test.html`. 1. Start a local server in the project root: `python -m http.server 8000` (or `npx http-server -p 8000`). 2. Open `http://localhost:8000/test.html`. 3. Click the **"Alle Tests ausfuehren"** button. 4. The summary at the top shows total / passed / failed; failed tests are listed below with the assertion message printed in red. **TDD verification convention used below:** - "Red": after pasting in a new `runner.test(...)`, reload `test.html` and click run. The new test must appear under "Fehlgeschlagen" with the expected error message. - "Green": after the implementation step, reload `test.html` and click run. The test must move to "Bestanden". The runner is global (`const runner = new TestRunner();`) and assertions are: `t.assertEqual(actual, expected, message)`, `t.assertAlmostEqual(actual, expected, tolerance, message)`, `t.assertTrue(value, message)`, `t.assertFalse(value, message)`. **Date gotcha (per `CLAUDE.md`):** always construct date literals as `new Date('YYYY-MM-DDT12:00:00')` to avoid timezone drift to the previous day. --- ## File Structure | Path | Status | Responsibility after this PR | |---|---|---| | `G:\Claude\Claude_tmp_dienstplan\variants.js` | **NEW** | Pure functions: `classify(date, holidayProvider)`, `classifyDuties(duties, holidayProvider)`, `variant1/2/3(classified, isVacation)`. Exposed on `window`. | | `G:\Claude\Claude_tmp_dienstplan\calculator.js` | MODIFIED | `BonusCalculator` becomes a thin orchestrator: calls `classifyDuties`, runs all three variants, picks the winner. New result shape `{ winner, allResults, totalBonus, classified, isVacation, dutyDetails }`. `calculateAllEmployees(employeeDuties, vacationMap)` gains a vacation map parameter. Old fields (`qualifyingDaysFriday`, `thresholdReached`, `bonusNormalDays`, `bonusQualifyingDays`, `qualifyingDaysDeducted`, `normalDaysPaid`, `qualifyingDaysPaid`, `qualifyingDays`) are removed. | | `G:\Claude\Claude_tmp_dienstplan\storage.js` | MODIFIED | Adds `STORAGE_KEY_VACATION = 'dienstplan_vacation'` plus `getVacationMode(name, yearMonth)` / `setVacationMode(name, yearMonth, value)`. `exportData()` includes the vacation map. `importData()` accepts the optional `vacation` field. `clearAll()` removes the new key. | | `G:\Claude\Claude_tmp_dienstplan\index.html` | MODIFIED | New `` into `index.html` between `holidays.js` and `calculator.js`.** Edit `G:\Claude\Claude_tmp_dienstplan\index.html`. Find: ```html ``` Replace with: ```html ``` - [ ] **Step 3: Insert `` into `test.html` between `holidays.js` and `calculator.js`.** Edit `G:\Claude\Claude_tmp_dienstplan\test.html`. Find: ```html ``` Replace with: ```html ``` - [ ] **Step 4: Manual verification - page still loads.** Start the local server and open `http://localhost:8000/index.html`. The app must render without a JS console error. Open `http://localhost:8000/test.html`, click "Alle Tests ausfuehren" - all existing tests still pass (the new `variants.js` is loaded but unused, so behavior is unchanged). - [ ] **Step 5: Commit.** ```bash git add variants.js index.html test.html git commit -m "feat: add variants.js skeleton and wire into index.html + test.html" ``` --- ### Task 2: Implement `classify(date, holidayProvider)` - red phase **Files:** - Test: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append to end, before `runAllTests`) - [ ] **Step 1: Add the 7 spec example tests for `classify`.** Append the following block to `test-suite.js` immediately before the `// Display Functions` divider (around line 476): ```javascript // ============================================================================ // Variants - classify() // ============================================================================ runner.test('classify: Karfreitag 2025 (Fr-Feiertag) -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-04-18T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Karfreitag (Fr) muss fr sein'); }); runner.test('classify: Ostermontag 2025 (Mo-Feiertag) -> so', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-04-21T12:00:00'); t.assertEqual(classify(date, hp), 'so', 'Ostermontag (Mo-Feiertag) muss so sein'); }); runner.test('classify: Christi Himmelfahrt 2025 (Do-Feiertag) -> so', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-05-29T12:00:00'); t.assertEqual(classify(date, hp), 'so', 'Do-Feiertag ohne Fr-Feiertag muss so sein'); }); runner.test('classify: Mi vor Christi Himmelfahrt 2025 -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-05-28T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Tag vor Mo-Do-Feiertag muss fr sein'); }); runner.test('classify: Tag der Deutschen Einheit 2025 (Fr-Feiertag) -> fr', (t) => { const hp = new HolidayProvider(); const date = new Date('2025-10-03T12:00:00'); t.assertEqual(classify(date, hp), 'fr', 'Fr-Feiertag muss fr sein'); }); runner.test('classify: Sandwich Do+Fr Feiertag -> Do=sa, Fr=fr', (t) => { // Use a fake HolidayProvider that flags Do AND Fr as Feiertag. const fakeHp = { isHoliday(date) { const day = date.getDay(); return day === 4 || day === 5; // Thu or Fri }, isDayBeforeHoliday(date) { const next = new Date(date); next.setDate(next.getDate() + 1); return this.isHoliday(next); } }; const thursday = new Date('2025-11-20T12:00:00'); // Donnerstag const friday = new Date('2025-11-21T12:00:00'); // Freitag t.assertEqual(classify(thursday, fakeHp), 'sa', 'Do Feiertag + Tag vor Fr Feiertag -> sa (Sandwich)'); t.assertEqual(classify(friday, fakeHp), 'fr', 'Fr Feiertag bleibt fr (Wochentag gewinnt)'); }); runner.test('classify: Sandwich Mo+Di Feiertag -> Mo=sa, Di=so', (t) => { const fakeHp = { isHoliday(date) { const day = date.getDay(); return day === 1 || day === 2; // Mon or Tue }, isDayBeforeHoliday(date) { const next = new Date(date); next.setDate(next.getDate() + 1); return this.isHoliday(next); } }; const monday = new Date('2025-11-24T12:00:00'); // Montag const tuesday = new Date('2025-11-25T12:00:00'); // Dienstag t.assertEqual(classify(monday, fakeHp), 'sa', 'Mo Feiertag + Tag vor Di Feiertag -> sa'); t.assertEqual(classify(tuesday, fakeHp), 'so', 'Di Feiertag (kein Sandwich, kein Tag-vor) -> so'); }); ``` - [ ] **Step 2: Red verification.** Reload `test.html`, click "Alle Tests ausfuehren". All 7 new `classify:` tests must appear under "Fehlgeschlagen" with the message `classify: not implemented`. - [ ] **Step 3: Commit.** ```bash git add test-suite.js git commit -m "test: add classify() spec example tests (red)" ``` --- ### Task 3: Implement `classify(date, holidayProvider)` - green phase **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace `classify` stub) - [ ] **Step 1: Replace the `classify` stub with the full implementation.** Edit `variants.js`. Replace the entire `function classify(date, holidayProvider) { ... }` block with: ```javascript function classify(date, holidayProvider) { const wd = date.getDay(); // 0=So, 1=Mo, ..., 5=Fr, 6=Sa // Real Fr/Sa/So always win if (wd === 5) return 'fr'; if (wd === 6) return 'sa'; if (wd === 0) return 'so'; // Mo-Do (wd 1..4) const isFeiertag = holidayProvider.isHoliday(date); const isTagVorFeiertag = holidayProvider.isDayBeforeHoliday(date); if (isFeiertag && isTagVorFeiertag) return 'sa'; // Sandwich-Tag if (isTagVorFeiertag) return 'fr'; // Tag vor Mo-Do-Feiertag if (isFeiertag) return 'so'; // Feiertag Mo-Do return 'weekday'; } ``` - [ ] **Step 2: Green verification.** Reload `test.html`, click "Alle Tests ausfuehren". All 7 new `classify:` tests must pass. No previously-passing test may regress. - [ ] **Step 3: Commit.** ```bash git add variants.js git commit -m "feat: implement classify(date, holidayProvider) day-slot mapping" ``` --- ### Task 4: Implement `classifyDuties(duties, holidayProvider)` - red + green **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append tests) - Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) - [ ] **Step 1: Append `classifyDuties` tests to `test-suite.js`.** Append immediately after the `classify:` tests added in Task 2: ```javascript runner.test('classifyDuties: leeres Array -> alle Slots 0', (t) => { const hp = new HolidayProvider(); const result = classifyDuties([], hp); t.assertEqual(result.fr, 0, 'fr=0'); t.assertEqual(result.sa, 0, 'sa=0'); t.assertEqual(result.so, 0, 'so=0'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); runner.test('classifyDuties: halbe Schicht auf Freitag zaehlt 0.5', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 0.5 } // Fr ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 0.5, 0.0001, 'fr=0.5'); t.assertEqual(result.sa, 0, 'sa=0'); t.assertEqual(result.so, 0, 'so=0'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); runner.test('classifyDuties: mehrere Dienste pro Slot summieren', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, // So { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo (weekday) { date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di (weekday) ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'fr=1.0'); t.assertAlmostEqual(result.sa, 1.0, 0.0001, 'sa=1.0'); t.assertAlmostEqual(result.so, 0.5, 0.0001, 'so=0.5'); t.assertAlmostEqual(result.weekday, 1.5, 0.0001, 'weekday=1.5'); }); runner.test('classifyDuties: Tag vor Feiertag (Mi vor Christi Himmelfahrt) zaehlt in fr', (t) => { const hp = new HolidayProvider(); const duties = [ { date: new Date('2025-05-28T12:00:00'), share: 1.0 } // Mi vor Christi Himmelfahrt ]; const result = classifyDuties(duties, hp); t.assertAlmostEqual(result.fr, 1.0, 0.0001, 'Mi-vor-Do-Feiertag -> fr'); t.assertEqual(result.weekday, 0, 'weekday=0'); }); ``` - [ ] **Step 2: Red verification.** Reload `test.html`. The 4 new `classifyDuties:` tests must fail with `classifyDuties: not implemented`. - [ ] **Step 3: Replace the `classifyDuties` stub in `variants.js`.** ```javascript function classifyDuties(duties, holidayProvider) { const result = { fr: 0, sa: 0, so: 0, weekday: 0 }; if (!Array.isArray(duties)) return result; for (const duty of duties) { const slot = classify(duty.date, holidayProvider); result[slot] += duty.share; } return result; } ``` - [ ] **Step 4: Green verification.** Reload `test.html`. All `classifyDuties:` tests pass. - [ ] **Step 5: Commit.** ```bash git add variants.js test-suite.js git commit -m "feat: implement classifyDuties() aggregation by slot" ``` --- ### Task 5: Implement `variant3(classified, isVacation)` - red + green This is V3 loose (the existing logic in `BonusCalculator`). Implementing this first lets us validate that historical inputs still produce the same numbers. **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) - Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) - [ ] **Step 1: Append `variant3` tests.** Append to `test-suite.js`: ```javascript // ============================================================================ // Variants - variant3 (loose: 2 qualifying days, pool fr+sa+so) // ============================================================================ runner.test('variant3: unter Schwelle (1 sa) -> not eligible, bonus 0', (t) => { const classified = { fr: 0, sa: 1, so: 0, weekday: 4 }; const r = variant3(classified, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); t.assertEqual(r.variantId, 3, 'variantId=3'); }); runner.test('variant3: 2x sa -> eligible, beide abgezogen, bonus 0', (t) => { const classified = { fr: 0, sa: 2, so: 0, weekday: 0 }; const r = variant3(classified, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.sa, 2, 'sa-deduction=2'); t.assertEqual(r.paidShares.sa, 0, 'sa-paid=0'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: Friday priority fr->so->sa', (t) => { // fr=2, sa=1, so=1, weekday=0 -> 2 von fr abgezogen, sa+so voll bezahlt const classified = { fr: 2, sa: 1, so: 1, weekday: 0 }; const r = variant3(classified, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 2, 'fr-deduction=2'); t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); t.assertEqual(r.paidShares.fr, 0, 'fr-paid=0'); t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.bonus, 2 * 450, 'bonus = 2 * 450 = 900'); }); runner.test('variant3: fr=1, sa=1, so=0 -> fr+sa abgezogen', (t) => { const classified = { fr: 1, sa: 1, so: 0, weekday: 0 }; const r = variant3(classified, false); t.assertEqual(r.deduction.fr, 1, 'fr=1'); t.assertEqual(r.deduction.so, 0, 'so=0'); t.assertEqual(r.deduction.sa, 1, 'sa=1'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: weekday wird voll bezahlt, nicht abgezogen', (t) => { const classified = { fr: 1, sa: 1, so: 0, weekday: 3 }; const r = variant3(classified, false); t.assertEqual(r.paidShares.weekday, 3, 'weekday-paid=3'); t.assertEqual(r.deduction.weekday, 0, 'weekday-deduction=0'); t.assertEqual(r.bonus, 3 * 250, 'bonus = 3 * 250 = 750'); }); runner.test('variant3: Urlaubsmodus halbiert Schwelle auf 1', (t) => { const classified = { fr: 0, sa: 0.5, so: 0.5, weekday: 0 }; const r = variant3(classified, true); t.assertTrue(r.eligible, 'eligible=true (Schwelle 1)'); // Abzug 1 aus Pool, fr-Prio -> so zuerst (fr=0), dann sa t.assertEqual(r.deduction.fr, 0, 'fr=0'); t.assertEqual(r.deduction.so, 0.5, 'so=0.5'); t.assertEqual(r.deduction.sa, 0.5, 'sa=0.5'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant3: Urlaubsmodus, halbe sa und 1 fr -> fr-Prio frisst 1', (t) => { const classified = { fr: 1, sa: 0.5, so: 0, weekday: 0 }; const r = variant3(classified, true); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 1, 'fr=1'); t.assertEqual(r.deduction.sa, 0, 'sa unangetastet'); t.assertEqual(r.paidShares.sa, 0.5, 'sa-paid=0.5'); t.assertEqual(r.bonus, 0.5 * 450, 'bonus = 0.5 * 450 = 225'); }); runner.test('variant3: threshold-Shape ist {pool: 2} normal, {pool: 1} im Urlaub', (t) => { const r1 = variant3({ fr: 0, sa: 2, so: 0, weekday: 0 }, false); const r2 = variant3({ fr: 0, sa: 1, so: 0, weekday: 0 }, true); t.assertEqual(r1.threshold.pool, 2, 'normal pool=2'); t.assertEqual(r2.threshold.pool, 1, 'vacation pool=1'); }); ``` - [ ] **Step 2: Red verification.** Reload `test.html`. All new `variant3:` tests fail with `variant3: not implemented`. - [ ] **Step 3: Implement `variant3` in `variants.js`.** Replace the `variant3` stub with: ```javascript function variant3(classified, isVacation) { const RATE_NORMAL = 250; const RATE_WEEKEND = 450; const poolThreshold = isVacation ? 1 : 2; const totalDeduction = isVacation ? 1 : 2; const pool = classified.fr + classified.sa + classified.so; const eligible = pool >= poolThreshold - 1e-9; if (!eligible) { return { variantId: 3, eligible: false, threshold: { pool: poolThreshold }, deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, bonus: 0, isWinner: false }; } // Friday priority: fr -> so -> sa let remaining = totalDeduction; const deduction = { fr: 0, sa: 0, so: 0, weekday: 0 }; for (const slot of ['fr', 'so', 'sa']) { const take = Math.min(remaining, classified[slot]); deduction[slot] = take; remaining -= take; if (remaining <= 1e-9) break; } const paidShares = { fr: Math.max(0, classified.fr - deduction.fr), sa: Math.max(0, classified.sa - deduction.sa), so: Math.max(0, classified.so - deduction.so), weekday: classified.weekday // weekday never deducted in V3 }; const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + paidShares.weekday * RATE_NORMAL; return { variantId: 3, eligible: true, threshold: { pool: poolThreshold }, deduction, paidShares, bonus, isWinner: false }; } ``` - [ ] **Step 4: Green verification.** Reload `test.html`. All `variant3:` tests pass. - [ ] **Step 5: Commit.** ```bash git add variants.js test-suite.js git commit -m "feat: implement variant3 (loose, pool fr+sa+so, fr-priority)" ``` --- ### Task 6: Implement `variant1(classified, isVacation)` - red + green **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) - Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) - [ ] **Step 1: Append `variant1` tests.** Append: ```javascript // ============================================================================ // Variants - variant1 (1 fr+so + 3 weekday) // ============================================================================ runner.test('variant1: Schwelle nicht erreicht (fr+so=0)', (t) => { const r = variant1({ fr: 0, sa: 5, so: 0, weekday: 3 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: Schwelle nicht erreicht (weekday<3)', (t) => { const r = variant1({ fr: 1, sa: 5, so: 0, weekday: 2 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: Spec-Beispiel fr=2,sa=1,so=0,weekday=4 -> 1150', (t) => { const r = variant1({ fr: 2, sa: 1, so: 0, weekday: 4 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 1, 'fr-deduction=1 (Fr-Prio)'); t.assertEqual(r.deduction.so, 0, 'so-deduction=0'); t.assertEqual(r.deduction.sa, 0, 'sa nicht abgezogen'); t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.so, 0, 'so-paid=0'); t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); }); runner.test('variant1: nur so vorhanden -> 1 von so abgezogen', (t) => { const r = variant1({ fr: 0, sa: 0, so: 1, weekday: 3 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.fr, 0, 'fr-deduction=0'); t.assertEqual(r.deduction.so, 1, 'so-deduction=1'); t.assertEqual(r.deduction.weekday, 3, 'weekday-deduction=3'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: sa wird voll bezahlt, nicht abgezogen', (t) => { const r = variant1({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); t.assertEqual(r.deduction.sa, 0, 'sa-deduction=0'); t.assertEqual(r.paidShares.sa, 2, 'sa-paid=2'); // bonus = (0+2+0)*450 + 0*250 = 900 t.assertEqual(r.bonus, 900, 'bonus=900'); }); runner.test('variant1: Urlaubsmodus halbiert Schwellen (0.5 + 1.5)', (t) => { const r = variant1({ fr: 0.5, sa: 0, so: 0, weekday: 1.5 }, true); t.assertTrue(r.eligible, 'eligible=true im Urlaub'); t.assertEqual(r.threshold.frSo, 0.5, 'threshold.frSo=0.5'); t.assertEqual(r.threshold.weekday, 1.5, 'threshold.weekday=1.5'); t.assertEqual(r.deduction.fr, 0.5, 'fr-deduction=0.5'); t.assertEqual(r.deduction.weekday, 1.5, 'weekday-deduction=1.5'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant1: threshold-Shape normal {frSo:1, weekday:3}', (t) => { const r = variant1({ fr: 1, sa: 0, so: 0, weekday: 3 }, false); t.assertEqual(r.threshold.frSo, 1, 'threshold.frSo=1'); t.assertEqual(r.threshold.weekday, 3, 'threshold.weekday=3'); }); ``` - [ ] **Step 2: Red verification.** All new `variant1:` tests fail with `variant1: not implemented`. - [ ] **Step 3: Implement `variant1`.** Replace the `variant1` stub in `variants.js`: ```javascript function variant1(classified, isVacation) { const RATE_NORMAL = 250; const RATE_WEEKEND = 450; const frSoThreshold = isVacation ? 0.5 : 1; const weekdayThreshold = isVacation ? 1.5 : 3; const frSoDeduction = isVacation ? 0.5 : 1; const weekdayDeduction = isVacation ? 1.5 : 3; const frSoPool = classified.fr + classified.so; const eligible = (frSoPool >= frSoThreshold - 1e-9) && (classified.weekday >= weekdayThreshold - 1e-9); if (!eligible) { return { variantId: 1, eligible: false, threshold: { frSo: frSoThreshold, weekday: weekdayThreshold }, deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, bonus: 0, isWinner: false }; } // Friday priority within fr+so pool: fr first, then so let remaining = frSoDeduction; const deduction = { fr: 0, sa: 0, so: 0, weekday: weekdayDeduction }; for (const slot of ['fr', 'so']) { const take = Math.min(remaining, classified[slot]); deduction[slot] = take; remaining -= take; if (remaining <= 1e-9) break; } const paidShares = { fr: Math.max(0, classified.fr - deduction.fr), sa: classified.sa, // sa never deducted in V1 so: Math.max(0, classified.so - deduction.so), weekday: Math.max(0, classified.weekday - deduction.weekday) }; const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + paidShares.weekday * RATE_NORMAL; return { variantId: 1, eligible: true, threshold: { frSo: frSoThreshold, weekday: weekdayThreshold }, deduction, paidShares, bonus, isWinner: false }; } ``` - [ ] **Step 4: Green verification.** All `variant1:` tests pass. - [ ] **Step 5: Commit.** ```bash git add variants.js test-suite.js git commit -m "feat: implement variant1 (1 fr+so + 3 weekday, fr-priority)" ``` --- ### Task 7: Implement `variant2(classified, isVacation)` - red + green **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append) - Modify: `G:\Claude\Claude_tmp_dienstplan\variants.js` (replace stub) - [ ] **Step 1: Append `variant2` tests.** ```javascript // ============================================================================ // Variants - variant2 (1 sa + 2 weekday) // ============================================================================ runner.test('variant2: Schwelle nicht erreicht (sa=0)', (t) => { const r = variant2({ fr: 5, sa: 0, so: 5, weekday: 3 }, false); t.assertFalse(r.eligible, 'eligible=false'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: Schwelle nicht erreicht (weekday<2)', (t) => { const r = variant2({ fr: 0, sa: 2, so: 0, weekday: 1 }, false); t.assertFalse(r.eligible, 'eligible=false'); }); runner.test('variant2: Spec-Beispiel fr=1,sa=2,so=0,weekday=3 -> 1150', (t) => { const r = variant2({ fr: 1, sa: 2, so: 0, weekday: 3 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.deduction.sa, 1, 'sa-deduction=1'); t.assertEqual(r.deduction.weekday, 2, 'weekday-deduction=2'); t.assertEqual(r.deduction.fr, 0, 'fr nicht abgezogen'); t.assertEqual(r.deduction.so, 0, 'so nicht abgezogen'); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.weekday, 1, 'weekday-paid=1'); t.assertEqual(r.bonus, 1150, 'bonus = (1+1+0)*450 + 1*250 = 1150'); }); runner.test('variant2: sa=1,weekday=2 -> alles weg, bonus 0', (t) => { const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); t.assertTrue(r.eligible, 'eligible=true'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: sa=2,weekday=2,fr=1,so=1 -> fr/so voll bezahlt', (t) => { const r = variant2({ fr: 1, sa: 2, so: 1, weekday: 2 }, false); t.assertEqual(r.paidShares.fr, 1, 'fr-paid=1'); t.assertEqual(r.paidShares.sa, 1, 'sa-paid=1'); t.assertEqual(r.paidShares.so, 1, 'so-paid=1'); t.assertEqual(r.paidShares.weekday, 0, 'weekday-paid=0'); t.assertEqual(r.bonus, 3 * 450, 'bonus = 3*450 = 1350'); }); runner.test('variant2: Urlaubsmodus halbiert (0.5 sa + 1 weekday)', (t) => { const r = variant2({ fr: 0, sa: 0.5, so: 0, weekday: 1 }, true); t.assertTrue(r.eligible, 'eligible=true im Urlaub'); t.assertEqual(r.threshold.sa, 0.5, 'threshold.sa=0.5'); t.assertEqual(r.threshold.weekday, 1, 'threshold.weekday=1'); t.assertEqual(r.deduction.sa, 0.5, 'sa-deduction=0.5'); t.assertEqual(r.deduction.weekday, 1, 'weekday-deduction=1'); t.assertEqual(r.bonus, 0, 'bonus=0'); }); runner.test('variant2: threshold-Shape normal {sa:1, weekday:2}', (t) => { const r = variant2({ fr: 0, sa: 1, so: 0, weekday: 2 }, false); t.assertEqual(r.threshold.sa, 1, 'threshold.sa=1'); t.assertEqual(r.threshold.weekday, 2, 'threshold.weekday=2'); }); ``` - [ ] **Step 2: Red verification.** All new `variant2:` tests fail with `variant2: not implemented`. - [ ] **Step 3: Implement `variant2`.** Replace the `variant2` stub in `variants.js`: ```javascript function variant2(classified, isVacation) { const RATE_NORMAL = 250; const RATE_WEEKEND = 450; const saThreshold = isVacation ? 0.5 : 1; const weekdayThreshold = isVacation ? 1 : 2; const saDeduction = isVacation ? 0.5 : 1; const weekdayDeduction = isVacation ? 1 : 2; const eligible = (classified.sa >= saThreshold - 1e-9) && (classified.weekday >= weekdayThreshold - 1e-9); if (!eligible) { return { variantId: 2, eligible: false, threshold: { sa: saThreshold, weekday: weekdayThreshold }, deduction: { fr: 0, sa: 0, so: 0, weekday: 0 }, paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 }, bonus: 0, isWinner: false }; } const deduction = { fr: 0, sa: saDeduction, so: 0, weekday: weekdayDeduction }; const paidShares = { fr: classified.fr, // fr never deducted in V2 sa: Math.max(0, classified.sa - deduction.sa), so: classified.so, // so never deducted in V2 weekday: Math.max(0, classified.weekday - deduction.weekday) }; const bonus = (paidShares.fr + paidShares.sa + paidShares.so) * RATE_WEEKEND + paidShares.weekday * RATE_NORMAL; return { variantId: 2, eligible: true, threshold: { sa: saThreshold, weekday: weekdayThreshold }, deduction, paidShares, bonus, isWinner: false }; } ``` - [ ] **Step 4: Green verification.** All `variant2:` tests pass. - [ ] **Step 5: Commit.** ```bash git add variants.js test-suite.js git commit -m "feat: implement variant2 (1 sa + 2 weekday)" ``` --- ### Task 8: Refactor `BonusCalculator` to use variants and pick the winner - red + green **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append winner-selection tests) - Modify: `G:\Claude\Claude_tmp_dienstplan\calculator.js` (full rewrite of business logic) - [ ] **Step 1: Append winner-selection tests.** ```javascript // ============================================================================ // 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 eligible -> V3 gewinnt egal wie klein der Bonus', (t) => { const hp = new HolidayProvider(); const calc = new BonusCalculator(hp); // fr=0, sa=2, so=0, weekday=0 -> V1 no (fr+so=0), V2 no (weekday=0), V3 yes (bonus=0) const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa { date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa ]; const result = calc.calculateMonthlyBonus(duties, false); t.assertEqual(result.winner.variantId, 3, 'V3 gewinnt (einzig eligible mit positive logic, V1/V2 also bonus 0)'); // Tie-breaker note: V1 bonus=0, V2 bonus=0, V3 bonus=0 -> strict > keeps V1 as winner. // The assertion above will FAIL for this scenario - V1 wins the tie. Adjust below. // (See implementation: strict > means V1 wins all-zero ties. Test must reflect that.) }); 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'); }); ``` **Important correction:** The test `'Winner: nur V3 eligible -> V3 gewinnt egal wie klein der Bonus'` as written above is **wrong** — with strict `>` for winner selection and all-zero ties going to V1, V3's `eligible=true` does not change the bonus value, so V1 wins the tie. Either replace the assertion or remove the test. The clean replacement to use instead: ```javascript 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'); }); ``` Use this corrected version; delete the prior broken one. - [ ] **Step 2: Red verification.** Reload `test.html`. The new winner tests fail because `BonusCalculator` still uses the old shape - specifically the assertions `result.winner.variantId` and `result.allResults.length` throw "Erwartet: ... / Erhalten: undefined". Also, **all existing** `Berechnung:` tests will still pass at this stage (we have not changed `calculator.js` yet). - [ ] **Step 3: Rewrite `calculator.js` to use variants.** Replace the **entire contents** of `G:\Claude\Claude_tmp_dienstplan\calculator.js` with: ```javascript /** * 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; this.RATE_WEEKEND = 450; } /** * 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 slot = classify(date, this.holidayProvider); return slot !== 'weekday'; } /** * Human-readable label for the date's day type (used by UI). */ getDayTypeLabel(date) { const dayOfWeek = date.getDay(); const isHoliday = this.holidayProvider.isHoliday(date); const holidayName = this.holidayProvider.getHolidayName(date); const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date); 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'; const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; return days[dayOfWeek]; } /** * Build the dutyDetails array (date, share, isQualifying, dayType) for the UI. */ 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(isVacation); } 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]; // 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]; } } winner.isWinner = true; return { classified, isVacation, winner, allResults: results, totalBonus: winner.bonus, totalDuties: duties.length, dutyDetails: this.buildDutyDetails(duties) }; } /** * Calculate for all employees. vacationMap: { [employeeName]: boolean } */ calculateAllEmployees(employeeDuties, vacationMap = {}) { const results = {}; for (const [name, duties] of Object.entries(employeeDuties)) { const isVac = Boolean(vacationMap[name]); results[name] = this.calculateMonthlyBonus(duties, isVac); } return results; } 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 { 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: [] }; } formatCurrency(amount) { return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(amount); } } // Make it available globally window.BonusCalculator = BonusCalculator; ``` - [ ] **Step 4: Green verification (winner tests).** Reload `test.html`. All new `Winner:` tests pass. **However**, the existing `Berechnung:` tests now fail because they reference removed fields like `result.qualifyingDays`, `result.thresholdReached`, `result.qualifyingDaysPaid`, `result.bonusNormalDays`, `result.normalDays`, etc. That's expected - Task 9 fixes them. - [ ] **Step 5: Commit.** ```bash git add calculator.js test-suite.js git commit -m "refactor: BonusCalculator runs all 3 variants and picks winner" ``` --- ### Task 9: Migrate existing tests to the new result shape **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (rewrite the `Berechnung:` block) The existing `Berechnung:` tests reference fields that no longer exist (`qualifyingDays`, `thresholdReached`, `qualifyingDaysPaid`, `qualifyingDaysDeducted`, `normalDays`, `normalDaysPaid`, `bonusNormalDays`, `bonusQualifyingDays`). Rewrite each test to use the new shape (`result.classified`, `result.winner.eligible`, `result.winner.paidShares`, `result.totalBonus`). - [ ] **Step 1: Replace the `Berechnung:` block.** Find the existing block that starts with `// Calculator Tests - Bonus Calculation` and the test `'Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0€'`. Replace the whole block, up to and including `'Berechnung: Keine Dienste = 0€'`, with: ```javascript // ============================================================================ // Calculator Tests - Bonus Calculation (new variants shape) // ============================================================================ runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 } // 1x Samstag ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.classified.sa, 1.0, 'sa=1.0'); t.assertFalse(result.winner.eligible, 'Kein eligible Variant'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: Genau 2.0 WE-Tage (Sa+So) -> V3 trigger, bonus 0', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertTrue(result.winner.eligible, 'V3 eligible'); t.assertEqual(result.winner.paidShares.sa + result.winner.paidShares.so, 0, '0 paid (alle abgezogen)'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: 4x halbe Sa+So Dienste (Schwelle 2.0) -> bonus 0', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, { date: new Date('2025-11-23T12:00:00'), share: 0.5 }, { date: new Date('2025-11-23T12:00:00'), share: 0.5 } ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.sa + result.classified.so, 2.0, 0.0001, '2.0 total'); t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: 3 WE-Tage (Fr+Sa+So) -> V3 winner, bonus 450 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Freitag { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); // V3: pool=3, abzug 2 (fr=1, so=1) -> paid sa=1 -> 450 t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertEqual(result.totalBonus, 450, 'bonus 450'); }); runner.test('Berechnung: Normale Tage + WE-Tage gemischt (Mo+Di+Sa+So) -> V3, bonus 500', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Montag { date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Dienstag { date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag { date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag ]; const result = calculator.calculateMonthlyBonus(duties, false); // V1: fr+so=1, weekday=2 < 3 -> not eligible // V2: sa=1, weekday=2 -> eligible, abzug 1 sa, 2 weekday -> 0 -> bonus 0 // V3: pool=2 -> eligible, abzug 2 (so=1, sa=1) -> 0 sa/so paid + 2 weekday paid = 500 t.assertEqual(result.winner.variantId, 3, 'V3 winner with weekday-pay'); t.assertEqual(result.winner.paidShares.weekday, 2, '2 weekday paid'); t.assertEqual(result.totalBonus, 500, '2 * 250 = 500'); }); runner.test('Berechnung: Halbe Dienste korrekt im neuen Shape', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-24T12:00:00'), share: 0.5 }, // halber Mo (weekday) { date: new Date('2025-11-22T12:00:00'), share: 0.5 }, // halber Sa { date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // ganzer So { date: new Date('2025-11-21T12:00:00'), share: 1.0 } // ganzer Fr ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.weekday, 0.5, 0.0001, 'weekday=0.5'); t.assertAlmostEqual(result.classified.fr + result.classified.sa + result.classified.so, 2.5, 0.0001, 'WE-Pool=2.5'); // V3: pool=2.5, abzug 2 (fr=1, so=1) -> paid sa=0.5, weekday=0.5 -> 0.5*450 + 0.5*250 = 350 t.assertEqual(result.winner.variantId, 3, 'V3 winner'); t.assertEqual(result.totalBonus, 350, 'bonus 350'); }); runner.test('Berechnung: Feiertag (1. Mai 2025 = Do) + Vortag (Mi)', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-04-30T12:00:00'), share: 1.0 }, // Mi vor 1. Mai -> fr { date: new Date('2025-05-01T12:00:00'), share: 1.0 } // 1. Mai (Do-Feiertag) -> so ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertAlmostEqual(result.classified.fr, 1.0, 0.0001, 'fr=1.0'); t.assertAlmostEqual(result.classified.so, 1.0, 0.0001, 'so=1.0'); // V3: pool=2, abzug 2 (fr=1, so=1) -> 0 paid -> bonus 0 t.assertEqual(result.totalBonus, 0, 'Bonus 0'); }); runner.test('Berechnung: Keine Dienste = 0 EUR', (t) => { const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const result = calculator.calculateMonthlyBonus([], false); t.assertEqual(result.totalDuties, 0, '0 duties'); t.assertEqual(result.totalBonus, 0, '0 bonus'); t.assertEqual(result.dutyDetails.length, 0, '0 dutyDetails'); }); ``` - [ ] **Step 2: Update the `Edge Case` block to also use the new shape.** Find the test `'Edge Case: Exakt Schwellenwert mit Rundungsfehler (1.9999)'`. Replace its body with: ```javascript const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2025-11-22T12:00:00'), share: 0.66666 }, // Sa { date: new Date('2025-11-23T12:00:00'), share: 0.66666 }, // So { date: new Date('2025-11-21T12:00:00'), share: 0.66666 } // Fr ]; const result = calculator.calculateMonthlyBonus(duties, false); const pool = result.classified.fr + result.classified.sa + result.classified.so; // 0.66666 x 3 ~ 1.99998 - wegen 1e-9 Toleranz triggert V3 t.assertTrue(result.winner.variantId === 3 || pool < 2.0, 'Rundung korrekt behandelt'); ``` Find `'Edge Case: Sehr viele Dienste (Performance)'`. Replace its body with: ```javascript const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = []; for (let i = 1; i <= 30; i++) { duties.push({ date: new Date(`2025-11-${String(i).padStart(2, '0')}T12:00:00`), share: i % 2 === 0 ? 1.0 : 0.5 }); } const start = Date.now(); const result = calculator.calculateMonthlyBonus(duties, false); const duration = Date.now() - start; t.assertTrue(duration < 100, `Berechnung schnell (${duration}ms)`); t.assertTrue(result.totalBonus > 0, 'Bonus > 0'); ``` Find `'Edge Case: Dienst am 29. Februar (Schaltjahr)'`. Replace its body with: ```javascript const holidays = new HolidayProvider(); const calculator = new BonusCalculator(holidays); const duties = [ { date: new Date('2028-02-29T12:00:00'), share: 1.0 } // Dienstag -> weekday ]; const result = calculator.calculateMonthlyBonus(duties, false); t.assertEqual(result.classified.weekday, 1.0, '29.02. (Di) = weekday'); ``` - [ ] **Step 3: Green verification.** Reload `test.html`. All tests - old (rewritten), `classify:`, `classifyDuties:`, `variant1/2/3:`, `Winner:`, `Edge Case:` - pass. - [ ] **Step 4: Commit.** ```bash git add test-suite.js git commit -m "test: migrate Berechnung + Edge-Case tests to new variants result shape" ``` --- ### Task 10: Extend `DataStorage` with vacation-mode persistence **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\test-suite.js` (append storage tests) - Modify: `G:\Claude\Claude_tmp_dienstplan\storage.js` - [ ] **Step 1: Append vacation-storage tests.** ```javascript // ============================================================================ // Storage - Vacation Mode // ============================================================================ runner.test('Storage: getVacationMode fuer unbekannten MA -> false', (t) => { const storage = new DataStorage(); storage.clearAll(); t.assertFalse(storage.getVacationMode('Niemand', '2025-11'), 'leerer Default false'); }); runner.test('Storage: setVacationMode -> getVacationMode round-trip', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.setVacationMode('Max Mustermann', '2025-11', true); t.assertTrue(storage.getVacationMode('Max Mustermann', '2025-11'), 'true round-trip'); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-12'), 'anderer Monat = false'); t.assertFalse(storage.getVacationMode('Anna Schmidt', '2025-11'), 'anderer MA = false'); }); runner.test('Storage: setVacationMode kann zurueckgesetzt werden', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.setVacationMode('Max Mustermann', '2025-11', true); storage.setVacationMode('Max Mustermann', '2025-11', false); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'wieder false'); }); runner.test('Storage: Export enthaelt dienstplan_vacation', (t) => { const storage = new DataStorage(); storage.clearAll(); storage.addEmployee('Max Mustermann'); storage.setVacationMode('Max Mustermann', '2025-11', true); const exported = storage.exportData(); const parsed = JSON.parse(exported); t.assertTrue('vacation' in parsed, 'vacation key im Export'); t.assertEqual(parsed.vacation['Max Mustermann']['2025-11'], true, 'Wert exportiert'); }); runner.test('Storage: Import restauriert vacation', (t) => { const storage1 = new DataStorage(); storage1.clearAll(); storage1.addEmployee('Max Mustermann'); storage1.setVacationMode('Max Mustermann', '2025-11', true); const exported = storage1.exportData(); const storage2 = new DataStorage(); storage2.clearAll(); const ok = storage2.importData(exported); t.assertTrue(ok, 'Import success'); t.assertTrue(storage2.getVacationMode('Max Mustermann', '2025-11'), 'vacation restauriert'); }); runner.test('Storage: Import ohne vacation-Feld bleibt fehlerfrei', (t) => { const storage = new DataStorage(); storage.clearAll(); const legacyJson = JSON.stringify({ employees: ['Max Mustermann'], duties: {} }); const ok = storage.importData(legacyJson); t.assertTrue(ok, 'Legacy import erfolgreich'); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'Default false'); }); runner.test('Storage: clearAll entfernt auch vacation', (t) => { const storage = new DataStorage(); storage.setVacationMode('Max Mustermann', '2025-11', true); storage.clearAll(); t.assertFalse(storage.getVacationMode('Max Mustermann', '2025-11'), 'nach clearAll false'); }); ``` - [ ] **Step 2: Red verification.** All new `Storage:` tests that touch vacation fail with `storage.getVacationMode is not a function` (or similar). - [ ] **Step 3: Modify `storage.js`.** In `G:\Claude\Claude_tmp_dienstplan\storage.js`, update the constructor: ```javascript constructor() { this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees'; this.STORAGE_KEY_DUTIES = 'dienstplan_duties'; this.STORAGE_KEY_VACATION = 'dienstplan_vacation'; } ``` Add new methods anywhere inside the `DataStorage` class (before `clearAll`): ```javascript /** * Get vacation mode for an employee in a specific month. * @param {string} employeeName * @param {string} yearMonth - format "YYYY-MM" * @returns {boolean} */ getVacationMode(employeeName, yearMonth) { try { const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); if (!raw) return false; const map = JSON.parse(raw); if (!map || typeof map !== 'object') return false; return Boolean(map[employeeName] && map[employeeName][yearMonth]); } catch (e) { console.error('Fehler beim Laden des Urlaubsmodus:', e); return false; } } /** * Set vacation mode for an employee in a specific month. */ setVacationMode(employeeName, yearMonth, value) { try { const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); const map = raw ? JSON.parse(raw) : {}; if (!map[employeeName]) map[employeeName] = {}; map[employeeName][yearMonth] = Boolean(value); localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(map)); } catch (e) { console.error('Fehler beim Speichern des Urlaubsmodus:', e); throw e; } } /** * Get the full vacation map ({ name: { yearMonth: bool } }). */ getAllVacationModes() { try { const raw = localStorage.getItem(this.STORAGE_KEY_VACATION); if (!raw) return {}; const map = JSON.parse(raw); if (!map || typeof map !== 'object') return {}; return map; } catch (e) { console.error('Fehler beim Laden des Urlaubsmodus:', e); return {}; } } ``` Update `clearAll()`: ```javascript clearAll() { localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES); localStorage.removeItem(this.STORAGE_KEY_DUTIES); localStorage.removeItem(this.STORAGE_KEY_VACATION); } ``` Update `exportData()`: ```javascript exportData() { try { return JSON.stringify({ employees: this.getEmployees(), duties: this.getAllDuties(), vacation: this.getAllVacationModes() }, null, 2); } catch (e) { console.error('Fehler beim Exportieren der Daten:', e); throw new Error('Fehler beim Exportieren der Daten: ' + e.message); } } ``` Update `importData(jsonString)`: ```javascript importData(jsonString) { try { const data = JSON.parse(jsonString); if (data.employees) { this.saveEmployees(data.employees); } if (data.duties) { this.saveAllDuties(data.duties); } if (data.vacation && typeof data.vacation === 'object') { localStorage.setItem(this.STORAGE_KEY_VACATION, JSON.stringify(data.vacation)); } return true; } catch (e) { console.error('Import failed:', e); return false; } } ``` - [ ] **Step 4: Green verification.** All new `Storage:` tests pass. - [ ] **Step 5: Commit.** ```bash git add storage.js test-suite.js git commit -m "feat: add dienstplan_vacation key + getVacationMode/setVacationMode + export/import" ``` --- ### Task 11: Wire vacationMap through `calculateAllEmployees` in `app.js` This task fixes the calculation tab so that when the user runs the calculation, each employee's vacation flag is read from storage and passed to `BonusCalculator`. **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` (method `calculateBonuses` only) - [ ] **Step 1: Modify `calculateBonuses` to read the vacationMap.** In `G:\Claude\Claude_tmp_dienstplan\app.js`, find `calculateBonuses()` (around line 331). Replace its body with: ```javascript calculateBonuses() { const monthSelect = document.getElementById('calc-month-select'); const yearSelect = document.getElementById('calc-year-select'); const resultsContainer = document.getElementById('calculation-results'); const month = parseInt(monthSelect.value); const year = parseInt(yearSelect.value); const yearMonth = `${year}-${String(month).padStart(2, '0')}`; const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month); // Build vacation map for this month: { name: boolean } const vacationMap = {}; Object.keys(employeeDuties).forEach(name => { vacationMap[name] = this.storage.getVacationMode(name, yearMonth); }); const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap); const monthNames = ['Januar', 'Februar', 'Maerz', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; resultsContainer.innerHTML = `
Keine Daten verfuegbar.
'; return; } // Stash current calc context for vacation-toggle handler this._currentCalcContext = { year, month, yearMonth }; employees.forEach(employeeName => { const result = results[employeeName]; const resultCard = this.createResultCard(employeeName, result); resultsContainer.appendChild(resultCard); }); this.showToast('Berechnung abgeschlossen.', 'success'); } ``` Note: keep the original umlaut spelling ("März", "Ergebnisse für", "verfügbar") that exists in the current file — the snippet uses ASCII placeholders to stay safe in markdown but the implementer must keep the existing German umlauts to match the rest of the codebase. - [ ] **Step 2: Manual verification.** Run the app. The result card currently still uses the old shape, so the card will be broken at this stage. That is acceptable - Task 12 replaces `createResultCard`. The console must not show errors about missing methods on `storage`. - [ ] **Step 3: Commit.** ```bash git add app.js git commit -m "feat: pass vacationMap from storage to calculateAllEmployees" ``` --- ### Task 12: Rewrite `createResultCard` - winner banner + collapsible variant breakdown + vacation toggle **Files:** - Modify: `G:\Claude\Claude_tmp_dienstplan\app.js` - Modify: `G:\Claude\Claude_tmp_dienstplan\styles.css` (append rules) - [ ] **Step 1: Replace `createResultCard(employeeName, result)` in `app.js`.** Find `createResultCard(employeeName, result) { ... }` (around line 365). Replace the **entire method** with: ```javascript /** * Create a result card for an employee (new variants shape). */ createResultCard(employeeName, result) { const card = document.createElement('div'); card.className = 'result-card'; const ctx = this._currentCalcContext || {}; const yearMonth = ctx.yearMonth || ''; const vacChecked = result.isVacation ? 'checked' : ''; const safeName = String(employeeName).replace(/"/g, '"'); const safeYm = String(yearMonth).replace(/"/g, '"'); // Header + vacation toggle let content = `Mit den eingetragenen Diensten erreicht keine der drei Varianten einen positiven Bonus.
Keine Bonuszahlung
Berechnungsregeln (Variante 2 - Streng):
` block). Replace it with: ```javascript html += `Berechnungsregeln (NRW Psychiatrie 2011):
Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan-Pro - NRW Psychiatrie 2011