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

23 KiB
Raw Blame History

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):

{ 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=12*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 frsosa
    • 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=02*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 frso
V2 sa (nur sa, keine Wahl)
V3 fr + sa + so frsosa

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:

{
  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 >= 1weekday >= 3 fr+so >= 0.5weekday >= 1.5
V1-Abzug 1 von fr+so, 3 von weekday 0.5 von fr+so, 1.5 von weekday
V2-Schwelle sa >= 1weekday >= 2 sa >= 0.5weekday >= 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.

{
  "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

// 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):

{ 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

// 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

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

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:

    <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: setVacationModegetVacationMode 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-v1v2).