Dienstplan-Pro/docs/specs/2026-05-11-bonus-varianten-design.md
Kenearos 1b515c7d54 docs: add design specs and implementation plans for bonus variants and image import
- Feature B: 3 Bonus-Varianten (V1/V2/V3 loose) + Urlaubsmodus + Feature C date stepper
- Feature A: Bild-Import via OpenRouter Vision-LLM

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:10:21 +02:00

617 lines
23 KiB
Markdown
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.

# Design Spec: Bonus-Varianten (NRW Psychiatrie 2011) + Date-Stepper
- **Datum:** 2026-05-11
- **Status:** Draft
- **Autor:** Design-Phase, Dienstplan-Pro
- **Scope:** Feature "Bonus-Varianten" (V1/V2/V3 mit Auto-Selection und Urlaubsmodus) + UX-Add-on "Feature C: Date-Stepper"
---
## 1. Ziel / Problemstellung
Aktuell rechnet Dienstplan-Pro Bonuszahlungen nach einer einzigen, vereinfachten Regel (siehe `BonusCalculator.calculateMonthlyBonus` in `calculator.js`): 2 qualifizierende Tage als Schwelle, danach 2 Tage Abzug mit Freitag-Priorität. Diese Logik entspricht hier intern "Variante 3 loose".
Die NRW-Psychiatrie-Vereinbarung von 2011 kennt jedoch **drei alternative Schwellenmodelle**. Welches Modell für einen Arzt/eine Ärztin in einem konkreten Monat den höheren Bonus ergibt, hängt von der Verteilung der Dienste über Wochentage, Wochenende und Feiertage ab.
**Ziel:** Alle drei Varianten parallel berechnen und automatisch die für den Arzt **beste** auswählen. Zusätzlich: einen Urlaubsmodus einführen, der bei ≥14 Tagen Abwesenheit alle Schwellen und Abzüge halbiert.
**Nicht-Ziel** dieses Specs: Verbreiterung der Tag-Slots, Backend-Migration, Multi-User-Support, andere Bundesländer.
---
## 2. Out of Scope
- Bildbasierte Diensteingabe (OCR/Foto-Import) separates Feature
- Server-/Cloud-Synchronisierung bleibt clientseitig (`localStorage`)
- Andere Bundesländer als NRW `HolidayProvider` bleibt unverändert
- Konfigurierbare Raten/Schwellen via UI Konstanten bleiben hartkodiert
- Migrationspfad für Bestandsdaten mit historischen, abweichenden Berechnungsergebnissen die neue Berechnung ist die aktuelle Wahrheit; alte Ergebnisse werden nicht "nachgeführt"
- Mehr als ein "Urlaubsmodus"-Toggle pro Mitarbeiter pro Monat (keine Teilurlaube)
---
## 3. Day Classification Rule (neu)
Jeder Dienst wird anhand seines Kalenderdatums **genau einem** Slot zugeordnet: `fr`, `sa`, `so` oder `weekday`.
### 3.1 Pseudo-Code
```
classify(date):
wd = date.getDay() // 0=So, 1=Mo, ..., 5=Fr, 6=Sa
if wd === 5: return "fr"
if wd === 6: return "sa"
if wd === 0: return "so"
// Mo-Do (wd 1..4)
isFeiertag = HolidayProvider.isHoliday(date)
isTagVorFeiertag = HolidayProvider.isDayBeforeHoliday(date)
if isFeiertag && isTagVorFeiertag: return "sa" // Sandwich-Tag wie Samstag
if isTagVorFeiertag: return "fr" // wie Freitag (Tag vor Feiertag)
if isFeiertag: return "so" // wie Sonntag (Feiertag selbst)
return "weekday"
```
**Wichtig:** Die echten Wochentage Fr/Sa/So gewinnen **immer**, unabhängig von Feiertagsstatus. Ein Feiertag, der auf einen Samstag fällt, bleibt `sa`. Ein Feiertag, der auf einen Freitag fällt, bleibt `fr` (und ein hypothetischer Donnerstag-Feiertag davor wird zum Sandwich-`sa`, siehe Tabelle).
### 3.2 Beispiele
| Datum | Wochentag | Feiertag? | Tag-vor-Feiertag? | Slot | Begründung |
|---|---|---|---|---|---|
| Karfreitag (z. B. 2025-04-18) | Fr | ja | nein | `fr` | Fr gewinnt immer |
| Ostermontag (z. B. 2025-04-21) | Mo | ja | nein | `so` | Feiertag Mo-Do → wie Sonntag |
| Christi Himmelfahrt (z. B. 2025-05-29) | Do | ja | nein (Fr kein Feiertag) | `so` | Feiertag Mo-Do, kein Sandwich → wie Sonntag |
| Mi vor Christi Himmelfahrt (z. B. 2025-05-28) | Mi | nein | ja | `fr` | Tag vor Feiertag Mo-Do → wie Freitag |
| Tag der Dt. Einheit 2025 (2025-10-03) | Fr | ja | nein | `fr` | Fr gewinnt immer |
| Hypothetisch: Do Feiertag + Fr Feiertag | Do | ja | ja | `sa` | Sandwich-Tag → wie Samstag |
| Hypothetisch: Mo Feiertag + Di Feiertag | Mo | ja | ja | `sa` | Sandwich-Tag → wie Samstag |
| Hypothetisch: Mo Feiertag + Di Feiertag | Di | ja | nein | `so` | Folge-Feiertag → wie Sonntag |
### 3.3 Aggregations-Output
Nach Klassifikation aller Dienste eines Monats entsteht ein In-Memory-Objekt mit kumulierten **Shares** (Halbdienste zählen 0.5):
```javascript
{ fr: 2.0, sa: 1.0, so: 1.5, weekday: 3.0 }
```
Dieses Objekt heißt im Folgenden **`classified`**.
---
## 4. Variant Definitions
Alle drei Varianten nehmen denselben `classified`-Input und liefern dasselbe Result-Shape (siehe Abschnitt 7). Eingangsgrößen:
- `classified = { fr, sa, so, weekday }` (Shares, Float)
- `isVacation: boolean` aus Urlaubsmodus
**Konstanten** (bleiben aus `BonusCalculator`):
- `RATE_NORMAL = 250` (für `weekday`)
- `RATE_WEEKEND = 450` (für `fr`, `sa`, `so`)
### 4.1 Variante 1 (V1) "1 (Fr/So) + 3 Weekday"
- **Schwelle:** `fr + so >= 1` UND `weekday >= 3`
- **Abzug bei Erfüllung:**
- vom `fr+so`-Pool: 1 (Friday-Priority: zuerst `fr`, dann `so`)
- von `weekday`: 3
- `sa` wird **nicht** abgezogen
- **Bezahlte Shares:**
- `fr_paid = fr - fr_deduction`
- `so_paid = so - so_deduction`
- `sa_paid = sa` (immer voll bezahlt)
- `weekday_paid = weekday - 3`
- **Bonus:**
- `(fr_paid + so_paid + sa_paid) * 450 + weekday_paid * 250`
**Beispiel V1:** classified = `{ fr: 2, sa: 1, so: 0, weekday: 4 }`, `isVacation=false`.
- Schwelle: `2 + 0 = 2 >= 1` ✓ und `4 >= 3` ✓ → eligible
- Abzug: 1 vom `fr` (Fr-Prio), 3 von `weekday`
- Paid: `fr=1, sa=1, so=0, weekday=1``(1+1+0)*450 + 1*250 = 900 + 250 = 1150 €`
### 4.2 Variante 2 (V2) "1 Sa + 2 Weekday"
- **Schwelle:** `sa >= 1` UND `weekday >= 2`
- **Abzug bei Erfüllung:**
- von `sa`: 1
- von `weekday`: 2
- `fr` und `so` werden **nicht** abgezogen
- **Bezahlte Shares:**
- `sa_paid = sa - 1`
- `weekday_paid = weekday - 2`
- `fr_paid = fr`
- `so_paid = so`
- **Bonus:**
- `(fr_paid + sa_paid + so_paid) * 450 + weekday_paid * 250`
**Beispiel V2:** classified = `{ fr: 1, sa: 2, so: 0, weekday: 3 }`, `isVacation=false`.
- Schwelle: `2 >= 1` ✓ und `3 >= 2` ✓ → eligible
- Abzug: 1 von `sa`, 2 von `weekday`
- Paid: `fr=1, sa=1, so=0, weekday=1``2*450 + 1*250 = 900 + 250 = 1150 €`
### 4.3 Variante 3 (V3 loose) "2 qualifying Days (Pool)"
Dies entspricht der **aktuell implementierten Logik** in `BonusCalculator`.
- **Schwelle:** `fr + sa + so >= 2`
- **Abzug bei Erfüllung:**
- aus dem Pool `fr + sa + so`: insgesamt 2, mit Priorität **`fr``so``sa`**
- `weekday` wird **nicht** abgezogen
- **Bezahlte Shares:**
- aus dem qualifying-Pool: jeweils Rest nach Abzug
- `weekday_paid = weekday` (immer voll)
- **Bonus:**
- `(fr_paid + sa_paid + so_paid) * 450 + weekday_paid * 250`
**Beispiel V3:** classified = `{ fr: 0, sa: 2, so: 0, weekday: 0 }`, `isVacation=false`.
- Schwelle: `0 + 2 + 0 = 2 >= 2` ✓ → eligible
- Abzug: `fr` leer → `so` leer → 2 von `sa`
- Paid: alle 0 → Bonus `0 €`
**Beispiel V3 mit Fr-Prio:** classified = `{ fr: 2, sa: 1, so: 1, weekday: 0 }`, `isVacation=false`.
- Schwelle: `4 >= 2` ✓ → eligible
- Abzug: 2 von `fr` (Fr-Prio erschöpft)
- Paid: `fr=0, sa=1, so=1, weekday=0``2*450 = 900 €`
### 4.4 Friday-Priority formale Regel
Innerhalb eines Abzugspools wird in dieser Reihenfolge entleert, bis die Abzugsmenge erreicht ist:
| Variante | Pool | Reihenfolge |
|---|---|---|
| V1 | `fr + so` | `fr``so` |
| V2 | `sa` | (nur `sa`, keine Wahl) |
| V3 | `fr + sa + so` | `fr``so``sa` |
Algorithmus (generisch):
```
function deductFromPool(amounts, order, total):
remaining = total
result = { ...amounts } // shallow copy
for slot in order:
take = min(remaining, result[slot])
result[slot] -= take
remaining -= take
if remaining <= 0: break
return result // paid shares per slot
```
**Hinweis:** Wegen der Eligibility-Checks (siehe oben) ist `remaining` am Ende stets `0`; sollte ein Floating-Point-Rest verbleiben (z. B. `1e-12`), wird dieser ignoriert. Für UI-Anzeige wird auf 2 Nachkommastellen gerundet.
### 4.5 No-Bonus-Case
Wenn eine Variante ihre Schwelle nicht erreicht:
```javascript
{
variantId: <id>,
eligible: false,
threshold: null,
deduction: null,
paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 },
bonus: 0,
isWinner: false // wird ggf. später noch true gesetzt (siehe 5)
}
```
Wenn **keine** Variante triggert, ist `totalBonus = 0 €` (wie heute).
---
## 5. Variant Selection & Tie-Breaker
```
function pickWinner(results):
// results = [r1, r2, r3] (immer 3 Einträge, auch nicht-eligible)
let winner = results[0]
for r in results[1..]:
if r.bonus > winner.bonus: winner = r
// Tie-Breaker: niedrigere variantId gewinnt → kein Update bei gleichem Bonus
winner.isWinner = true
return { winner, allResults: results, totalBonus: winner.bonus }
```
- Sieger = Variante mit dem höchsten `bonus`.
- **Tie-Breaker:** Bei Gleichstand gewinnt die niedrigere `variantId` (V1 < V2 < V3).
- Wenn alle drei `bonus === 0`: V1 ist nominell Winner (`isWinner=true` auf V1), aber `totalBonus = 0 €` und die UI zeigt "Keine Variante triggert".
---
## 6. Vacation Mode ("Urlaubsmodus")
### 6.1 Trigger
- Pro Mitarbeiter pro Monat: ein Boolean-Flag.
- UI-Label: **"Urlaub gehabt (≥14 Tage frei)"**
- Fachliche Begründung: 10 Werktage Urlaub + zwei Wochenenden 14 Kalendertage Abwesenheit.
### 6.2 Effekt
**Alle** Schwellen **und** Abzüge der drei Varianten werden halbiert. Halbe Werte sind explizit erlaubt:
| | Normal | Urlaubsmodus |
|---|---|---|
| V1-Schwelle | `fr+so >= 1` `weekday >= 3` | `fr+so >= 0.5` `weekday >= 1.5` |
| V1-Abzug | 1 von `fr+so`, 3 von `weekday` | 0.5 von `fr+so`, 1.5 von `weekday` |
| V2-Schwelle | `sa >= 1` `weekday >= 2` | `sa >= 0.5` `weekday >= 1` |
| V2-Abzug | 1 von `sa`, 2 von `weekday` | 0.5 von `sa`, 1 von `weekday` |
| V3-Schwelle | `fr+sa+so >= 2` | `fr+sa+so >= 1` |
| V3-Abzug | 2 aus Pool | 1 aus Pool |
Raten bleiben unverändert (250 / 450).
### 6.3 Persistenz
Neuer `localStorage`-Key: **`dienstplan_vacation`**.
```javascript
{
"Max Mustermann": {
"2025-11": true,
"2025-12": false
},
"Anna Schmidt": {
"2025-11": true
}
}
```
- Fehlender Eintrag `false`.
- Toggle in der UI schreibt sofort durch (kein "Speichern"-Button).
---
## 7. Data Model
### 7.1 In-Memory während Berechnung
```javascript
// Klassifizierte Shares pro Slot
const classified = { fr: 2.0, sa: 1.0, so: 1.5, weekday: 3.0 };
// Result einer Variante
const variantResult = {
variantId: 1, // 1 | 2 | 3
eligible: true, // Schwelle erfüllt?
threshold: { frSo: 1, weekday: 3 } /* o. ä. */ , // halbiert wenn Urlaub
deduction: { fr: 1, so: 0, sa: 0, weekday: 3 }, // tatsächlich abgezogen
paidShares: { fr: 1.0, sa: 1.0, so: 1.5, weekday: 0 }, // nach Abzug
bonus: 1825, // 0 wenn not eligible
isWinner: true
};
// Gesamt-Output von BonusCalculator
const finalResult = {
winner: variantResult, // Referenz auf das gewinnende variantResult
allResults: [v1Result, v2Result, v3Result],
totalBonus: 1825,
// Plus die Felder, die die UI/Reports heute schon erwarten:
classified,
isVacation: false,
dutyDetails: [/* unverändert wie heute */]
};
```
**Threshold-Shape pro Variante** (für `threshold`-Feld):
| Variante | Shape |
|---|---|
| V1 | `{ frSo: 1, weekday: 3 }` (im Urlaub `0.5` / `1.5`) |
| V2 | `{ sa: 1, weekday: 2 }` (im Urlaub `0.5` / `1` ) |
| V3 | `{ pool: 2 }` (im Urlaub `1`) |
**Deduction-Shape pro Variante** (immer 4 Felder, nicht genutzte = 0):
```javascript
{ fr: <num>, sa: <num>, so: <num>, weekday: <num> }
```
### 7.2 Persistenz
| Key | Verwendung | Status |
|---|---|---|
| `dienstplan_employees` | Mitarbeiterliste | **unverändert** |
| `dienstplan_duties` | Dienste pro MA pro Monat | **unverändert** |
| `dienstplan_vacation` | Urlaubsflag pro MA pro Monat | **NEU** |
Kein Migrationsschritt nötig beim ersten Lesen liefert ein fehlender Key `{}`/`false`.
---
## 8. Architecture & File Changes
### 8.1 Übersicht
| Datei | Änderung |
|---|---|
| `calculator.js` | Refactor: `BonusCalculator` bleibt als öffentliche API, ruft intern `variants.js` auf und wählt Sieger |
| `variants.js` | **NEU** enthält `classifyDuties(duties, holidayProvider)` und `variant1/2/3(classified, isVacation)` |
| `storage.js` | **+** `setVacationMode(name, yearMonth, bool)` und `getVacationMode(name, yearMonth)`, neuer Key `dienstplan_vacation` |
| `app.js` | UI-Logik: Urlaubs-Checkbox pro MA, Result-Card mit Sieger + `<details>` für alle Varianten, Date-Stepper |
| `index.html` | Markup-Ergänzungen + Script-Reihenfolge |
| `styles.css` | Variant-Badges, Stepper-Buttons, `<details>` |
| `test-suite.js` | Neue Test-Kategorien (siehe Abschnitt 11) |
### 8.2 Script-Load-Reihenfolge in `index.html`
```
holidays.js → variants.js → calculator.js → storage.js → app.js
```
`variants.js` **muss vor** `calculator.js` geladen werden, da `BonusCalculator` die Variant-Funktionen aufruft.
### 8.3 `variants.js` Public API
```javascript
// classifyDuties: gruppiert Dienste in Slots, summiert Shares
// duties = [{ date: Date, share: number }, ...]
// holidayProvider = instance von HolidayProvider
// returns { fr, sa, so, weekday }
function classifyDuties(duties, holidayProvider) { ... }
// variant1/2/3: berechnen eine Variante
// classified = { fr, sa, so, weekday }
// isVacation = boolean
// returns variantResult (siehe 7.1)
function variant1(classified, isVacation) { ... }
function variant2(classified, isVacation) { ... }
function variant3(classified, isVacation) { ... }
window.classifyDuties = classifyDuties;
window.variant1 = variant1;
window.variant2 = variant2;
window.variant3 = variant3;
```
### 8.4 `calculator.js` neue interne Struktur
```javascript
class BonusCalculator {
constructor(holidayProvider) {
this.holidayProvider = holidayProvider;
this.RATE_NORMAL = 250;
this.RATE_WEEKEND = 450;
}
calculateMonthlyBonus(duties, isVacation = false) {
if (!duties || duties.length === 0) return this.getEmptyResult();
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];
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,
dutyDetails: this.buildDutyDetails(duties) // wie bisher
};
}
// calculateAllEmployees: zusätzlicher Parameter vacationMap : { [name]: boolean }
calculateAllEmployees(employeeDuties, vacationMap = {}) { ... }
// Helfer wie getDayTypeLabel, formatCurrency, getEmptyResult bleiben
}
```
**Bestehende Felder im Result, die durch den Umbau wegfallen** (heute: `qualifyingDaysFriday`, `qualifyingDaysOther`, `thresholdReached`, `bonusNormalDays` etc.): Die UI muss auf das neue Shape (`winner.*`, `allResults`) umgestellt werden. Da `app.js` ohnehin angefasst wird, ist das Teil dieses PRs und es entsteht **kein** Parallelpfad.
### 8.5 `storage.js` Erweiterung
```javascript
class DataStorage {
constructor() {
this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
this.STORAGE_KEY_VACATION = 'dienstplan_vacation'; // NEU
}
// ---- bestehende Methoden unverändert ----
getVacationMode(employeeName, yearMonth) {
try {
const raw = localStorage.getItem(this.STORAGE_KEY_VACATION);
if (!raw) return false;
const map = JSON.parse(raw);
return Boolean(map?.[employeeName]?.[yearMonth]);
} catch (e) {
console.error('Fehler beim Laden des Urlaubsmodus:', e);
return false;
}
}
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;
}
}
// exportData / importData: dienstplan_vacation in JSON aufnehmen.
// clearAll: dienstplan_vacation ebenfalls entfernen.
}
```
`exportData()` und `importData()` werden um den Vacation-Key erweitert. `clearAll()` löscht ihn mit. Fehlt der Key im Import-JSON: kein Fehler, Standard ist `false`.
---
## 9. UI Changes
### 9.1 Tab "Berechnung"
- Pro Mitarbeiter eine **Urlaubs-Checkbox** direkt neben dem Namen:
```html
<label class="vacation-toggle">
<input type="checkbox" id="vacation-{employeeId}-{yearMonth}">
Urlaub gehabt (≥14 Tage frei)
</label>
```
Bei Toggle: sofort `storage.setVacationMode(name, ym, checked)` aufrufen und Berechnung neu rendern.
- **Result-Card pro Mitarbeiter:**
- Prominenter Header: **"Variante {1|2|3} → {bonus} €"** mit Stern-Badge ⭐
- Klappbares `<details>`-Element mit Label **"Alle Varianten anzeigen"**:
- Pro Variante eine Zeile / kleines Sub-Panel mit:
- Variantennummer und Kurzbeschreibung
- Schwelle (Soll-Wert, halbiert bei Urlaub)
- Eligibility-Check (✓ / ✗)
- Abzug pro Slot
- Paid Shares pro Slot
- Berechneter Bonus
- ⭐ neben dem Sieger
- Wenn `isVacation === true`: dezenter Hinweisbadge "Urlaubsmodus aktiv Schwellen halbiert".
### 9.2 Tab "Einstellungen"
Info-Box mit den Berechnungsregeln aktualisieren:
- Beschreibung aller drei Varianten (V1/V2/V3) inkl. Schwellen und Abzügen
- Hinweis auf Auto-Selection ("die Variante mit dem höchsten Bonus gewinnt; bei Gleichstand gewinnt die niedrigste Variante")
- Erklärung Urlaubsmodus (Auslöser ≥14 Tage frei, Effekt Halbierung)
- Tabelle der Day-Classification-Regeln (mind. die 5 echten Beispiele aus 3.2)
### 9.3 Toast-Verhalten
Beim Toggle der Urlaubs-Checkbox: kein Toast (zu häufig). Bei Fehler beim Schreiben: `app.showToast('Urlaubsmodus konnte nicht gespeichert werden', 'error')`.
---
## 10. Feature C Date-Stepper (UX-Add-on)
### 10.1 Ziel
Schnelleres Eintragen aufeinanderfolgender Dienste im Tab **"Dienste eintragen"** ohne den nativen Datepicker zu öffnen.
### 10.2 Verhalten
- Zwei Buttons **``** und **``** direkt neben dem Datums-Input.
- `` → setzt Datum auf vorherigen Tag.
- `` → setzt Datum auf nächsten Tag.
- **Clamp:** Datum darf den **aktuell ausgewählten Monat** (aus dem Monatsauswahl-Dropdown des Tabs) nicht verlassen.
- Ist das Datum bereits der 1. des Monats: `` ist disabled.
- Ist das Datum bereits der letzte Tag des Monats: `` ist disabled.
- Initialer State der Buttons wird beim Tab-Wechsel und beim Monatswechsel aktualisiert.
### 10.3 Implementation Notes
- Reine `app.js`-Änderung; keine Anpassung in `calculator.js` oder `storage.js`.
- Berechnung des letzten Tages des Monats: `new Date(year, month, 0).getDate()` (mit `month` 1-basiert).
- Datumsobjekt analog zur bestehenden Konvention mit `T12:00:00` setzen, um Timezone-Edge-Cases zu vermeiden.
---
## 11. Backwards Compatibility
- **Storage-Keys:** `dienstplan_employees` und `dienstplan_duties` bleiben in Shape und Inhalt **unverändert**. Nur `dienstplan_vacation` kommt hinzu (keine Migration nötig).
- **Berechnungslogik:** Die alte Single-Path-Logik wird **ersetzt**, nicht parallel geführt. V3 loose entspricht funktional der bisherigen Berechnung, daher bleiben für die überwiegende Mehrheit der historischen Eingaben die Ergebnisse identisch (V3 ist in diesen Fällen der Winner).
- **Mögliche Unterschiede zu vorher:** Wenn V1 oder V2 in einem historischen Monat einen höheren Bonus liefern würden als V3, wird ab sofort dieser höhere Bonus angezeigt. Das ist gewünscht.
- **Export/Import:** Alte Backup-JSONs ohne `dienstplan_vacation` werden weiter akzeptiert; der Modus startet dann auf `false`.
- **PWA-Cache:** `sw.js` muss bei Release die Cache-Version inkrementieren, damit `variants.js` und die neuen Assets ausgeliefert werden.
---
## 12. Test Plan (nur Kategorien, keine Implementierung)
Neue Tests in `test-suite.js`. Keine Code-Implementierung in diesem Spec.
### 12.1 `classifyDuties` / `classify(date)`
Abdeckung aller 7 Fälle aus Abschnitt 3.2:
1. Fr-Feiertag → `fr`
2. Mo-Feiertag (Ostermontag) → `so`
3. Do-Feiertag ohne Fr-Feiertag → `so`
4. Mi vor Do-Feiertag → `fr`
5. Tag der Deutschen Einheit 2025 (Fr) → `fr`
6. Hypothetisch: Do = Feiertag UND Fr = Feiertag → Do = `sa` (Sandwich), Fr = `fr`
7. Hypothetisch: Mo = Feiertag UND Di = Feiertag → Mo = `sa`, Di = `so`
Plus:
- Halbschicht (0.5) auf einen `fr` zählt korrekt mit `+0.5` im Slot.
- Mehrere Dienste pro Slot summieren.
- Leeres Duty-Array → `{ fr:0, sa:0, so:0, weekday:0 }`.
### 12.2 `variant1`
- Eligible / nicht eligible (Schwellen jeweils gerade über/unter Grenze).
- Mit und ohne Urlaubsmodus (Halbierung).
- Friday-Priority im `fr+so`-Pool (zuerst `fr`, dann `so`).
- Edge: nur `fr` vorhanden, ausreichend, `weekday=3` → triggert.
- Edge: nur `so` vorhanden, `weekday=3` → triggert (1 von `so` abgezogen).
### 12.3 `variant2`
- Eligible / nicht eligible.
- Mit und ohne Urlaubsmodus.
- Edge: `sa=1, weekday=2` → triggert, alles wird abgezogen, Bonus = 0.
- Edge: `sa=2, weekday=2, fr=1, so=1` → triggert, `fr`/`so` voll bezahlt.
### 12.4 `variant3` (loose)
- Loose Trigger: `sa=2` allein reicht.
- Friday-Priority im Pool (`fr` zuerst, dann `so`, dann `sa`).
- Mit und ohne Urlaubsmodus (Schwelle 1 statt 2).
- Verhalten identisch zu heutigem `BonusCalculator` für eine Stichprobe historischer Inputs.
### 12.5 Winner Selection
- Klarer Winner V1 (z. B. classified begünstigt `weekday`-haltige Variante).
- Klarer Winner V2.
- Klarer Winner V3.
- Tie V1=V2: V1 gewinnt.
- Tie V2=V3: V2 gewinnt.
- Tie V1=V2=V3 (alle 0 €): V1 ist nominell Winner, `totalBonus=0`.
- Eine Variante eligible, die anderen nicht → eligible gewinnt unabhängig vom Bonus-Wert (da nicht-eligible Bonus = 0).
### 12.6 Vacation Mode (kombiniert)
- V1 mit Urlaubsmodus: Schwelle `fr+so>=0.5`, `weekday>=1.5`, Abzüge halbiert.
- V3 mit Urlaubsmodus: `fr+sa+so>=1` triggert bereits mit einer Halbschicht auf `sa`.
- Toggle in Storage: `setVacationMode` → `getVacationMode` round-trip.
- Storage: fehlender Key → `false`; ungültiges JSON → `false` (kein Throw nach außen).
### 12.7 Bestehende Tests
- Tests, die heute "Variante 3"-Verhalten prüfen, sollten überwiegend grün bleiben, weil V3 loose = aktuelle Logik.
- Anzupassen: alle Tests, die auf Felder wie `qualifyingDaysFriday`/`thresholdReached`/`bonusNormalDays` zugreifen diese sind im neuen Result-Shape nicht mehr Top-Level, sondern unter `winner.deduction` / `winner.paidShares` / `winner.eligible`.
### 12.8 Feature C Date-Stepper
- `` am Monatsanfang ist disabled, ändert das Datum nicht.
- `` am Monatsende ist disabled, ändert das Datum nicht.
- `` / `` in der Monatsmitte ändern um genau ±1 Tag.
- Monatswechsel im Dropdown setzt Datum auf 1. des neuen Monats und aktualisiert Buttons-State.
---
## 13. Open Questions
Keine blockierenden offenen Punkte. Minor, zur Klärung in der Implementierungsphase:
- Soll der Urlaubsmodus-Status in der CSV/HTML-Exportausgabe sichtbar vermerkt werden? (Vorschlag: ja, als Zusatzspalte / Hinweis im Header.)
- Soll im Tab "Berechnung" eine Gesamt-Summe aller Mitarbeiter über alle Sieger-Varianten weiterhin angezeigt werden? (Vorschlag: ja, wie heute.)
- PWA-Cache-Version-Bump: separate Mini-Task im selben PR (Bumping `dienstplan-pro-v1` → `v2`).