Merge feature/bonus-varianten: 3 variants + vacation + date-stepper

Conflicts resolved:
- sw.js: bumped CACHE_NAME to dienstplan-pro-v4 (was v3 + v2). Both
  variants.js and image-import.js are in ASSETS.
- storage.js: kept STORAGE_KEY_DUTIES + STORAGE_KEY_VACATION (Feature B)
  alongside STORAGE_KEY_OPENROUTER_KEY/MODEL + DEFAULT_MODEL (Feature A).
- styles.css: appended Feature B variants/vacation/date-stepper rules
  after Feature A modal/key rules; both blocks coexist.
This commit is contained in:
Kenearos 2026-05-12 18:45:31 +02:00
commit 9a26d8b9ef
9 changed files with 1375 additions and 423 deletions

449
app.js
View file

@ -43,8 +43,13 @@ class DienstplanApp {
// Duty management
document.getElementById('add-duty-btn').addEventListener('click', () => this.addDuty());
document.getElementById('employee-select-duty').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
document.getElementById('month-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
document.getElementById('year-select').addEventListener('change', () => this.loadDutiesForSelectedEmployee());
// Date stepper buttons (Feature C)
document.getElementById('duty-date-prev').addEventListener('click', () => this.stepDutyDate(-1));
document.getElementById('duty-date-next').addEventListener('click', () => this.stepDutyDate(+1));
document.getElementById('duty-date').addEventListener('change', () => this.updateDateStepperState());
document.getElementById('month-select').addEventListener('change', () => this.onDutyMonthChange());
document.getElementById('year-select').addEventListener('change', () => this.onDutyMonthChange());
// Bild-Import (Feature A)
const imageImportBtn = document.getElementById('open-image-import-btn');
@ -131,6 +136,8 @@ class DienstplanApp {
// Set date input to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('duty-date').value = today;
this.updateDateStepperState();
}
/**
@ -294,6 +301,86 @@ class DienstplanApp {
this.loadDutiesForSelectedEmployee();
}
/**
* Step the duty-date input by +/-1 day, clamped to the currently selected month.
*/
stepDutyDate(delta) {
const dateInput = document.getElementById('duty-date');
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const month = parseInt(monthSelect.value);
const year = parseInt(yearSelect.value);
const lastDay = new Date(year, month, 0).getDate();
if (!dateInput.value) {
// Initialize to 1st of the selected month
dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`;
this.updateDateStepperState();
return;
}
const cur = new Date(dateInput.value + 'T12:00:00');
// If outside selected month, snap to 1st
const inMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month);
if (!inMonth) {
dateInput.value = `${year}-${String(month).padStart(2, '0')}-01`;
this.updateDateStepperState();
return;
}
const curDay = cur.getDate();
const newDay = curDay + delta;
if (newDay < 1 || newDay > lastDay) return; // clamp
const newDate = new Date(year, month - 1, newDay, 12, 0, 0);
const yyyy = newDate.getFullYear();
const mm = String(newDate.getMonth() + 1).padStart(2, '0');
const dd = String(newDate.getDate()).padStart(2, '0');
dateInput.value = `${yyyy}-${mm}-${dd}`;
this.updateDateStepperState();
}
/**
* Update the disabled state of the stepper buttons based on current date / month.
*/
updateDateStepperState() {
const dateInput = document.getElementById('duty-date');
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const prevBtn = document.getElementById('duty-date-prev');
const nextBtn = document.getElementById('duty-date-next');
if (!dateInput || !prevBtn || !nextBtn) return;
const month = parseInt(monthSelect.value);
const year = parseInt(yearSelect.value);
const lastDay = new Date(year, month, 0).getDate();
if (!dateInput.value) {
prevBtn.disabled = false;
nextBtn.disabled = false;
return;
}
const cur = new Date(dateInput.value + 'T12:00:00');
const inSelectedMonth = (cur.getFullYear() === year) && ((cur.getMonth() + 1) === month);
if (!inSelectedMonth) {
prevBtn.disabled = false;
nextBtn.disabled = false;
return;
}
prevBtn.disabled = cur.getDate() <= 1;
nextBtn.disabled = cur.getDate() >= lastDay;
}
/**
* Handle month/year change in the duty tab: set date to 1st of new month, refresh list, refresh stepper.
*/
onDutyMonthChange() {
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const month = parseInt(monthSelect.value);
const year = parseInt(yearSelect.value);
document.getElementById('duty-date').value = `${year}-${String(month).padStart(2, '0')}-01`;
this.updateDateStepperState();
this.loadDutiesForSelectedEmployee();
}
/**
* Load duties for the selected employee and month
*/
@ -364,9 +451,17 @@ class DienstplanApp {
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);
const results = this.calculator.calculateAllEmployees(employeeDuties);
// 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', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
@ -379,6 +474,9 @@ class DienstplanApp {
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);
@ -389,70 +487,134 @@ class DienstplanApp {
}
/**
* Create a result card for an employee
* Create a result card for an employee (new variants shape).
*/
createResultCard(employeeName, result) {
const card = document.createElement('div');
card.className = 'result-card';
let content = `<h3>${employeeName}</h3>`;
const ctx = this._currentCalcContext || {};
const yearMonth = ctx.yearMonth || '';
const vacChecked = result.isVacation ? 'checked' : '';
const safeName = String(employeeName).replace(/"/g, '&quot;');
const safeYm = String(yearMonth).replace(/"/g, '&quot;');
if (!result.thresholdReached) {
// Header + vacation toggle
let content = `
<div class="result-header">
<h3>${employeeName}</h3>
<label class="vacation-toggle">
<input type="checkbox"
data-vacation-employee="${safeName}"
data-vacation-yearmonth="${safeYm}"
${vacChecked}>
Urlaub gehabt (14 Tage frei)
</label>
</div>
`;
if (result.isVacation) {
content += `<div class="vacation-active-banner">Urlaubsmodus aktiv - Schwellen halbiert</div>`;
}
// Winner banner
if (!result.winner.eligible || result.totalBonus === 0) {
content += `
<div class="threshold-warning">
<h4>Schwellenwert nicht erreicht</h4>
<p>Es wurden nur ${result.qualifyingDays.toFixed(1)} qualifizierende Tage gearbeitet.
Mindestens ${this.calculator.MIN_QUALIFYING_DAYS} Tage erforderlich.</p>
<h4>Keine Variante triggert</h4>
<p>Mit den eingetragenen Diensten erreicht keine der drei Varianten einen positiven Bonus.</p>
<p><strong>Keine Bonuszahlung</strong></p>
</div>
`;
} else {
content += `
<div class="result-summary">
<div class="result-item">
<div class="result-label">Normale Tage</div>
<div class="result-value">${result.normalDays.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag Tage</div>
<div class="result-value">${result.qualifyingDays.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">Abzug</div>
<div class="result-value danger">-${result.qualifyingDaysDeducted.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">Normale Tage (bezahlt)</div>
<div class="result-value success">${result.normalDaysPaid.toFixed(1)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag (bezahlt)</div>
<div class="result-value success">${result.qualifyingDaysPaid.toFixed(1)}</div>
</div>
</div>
<div class="result-summary">
<div class="result-item">
<div class="result-label">Normale Tage (250)</div>
<div class="result-value">${this.calculator.formatCurrency(result.bonusNormalDays)}</div>
</div>
<div class="result-item">
<div class="result-label">WE/Feiertag (450)</div>
<div class="result-value">${this.calculator.formatCurrency(result.bonusQualifyingDays)}</div>
</div>
</div>
<div class="bonus-total">
<h4>Gesamtbonus</h4>
<h4>Variante ${result.winner.variantId} <span class="variant-badge winner"> Sieger</span></h4>
<div class="amount">${this.calculator.formatCurrency(result.totalBonus)}</div>
</div>
`;
}
// Classified summary line
const c = result.classified;
content += `
<div class="classified-summary">
<span>Fr: <strong>${c.fr.toFixed(1)}</strong></span>
<span>Sa: <strong>${c.sa.toFixed(1)}</strong></span>
<span>So: <strong>${c.so.toFixed(1)}</strong></span>
<span>Werktage: <strong>${c.weekday.toFixed(1)}</strong></span>
</div>
`;
// Collapsible variant breakdown
content += `<details class="variant-details"><summary>Alle Varianten anzeigen</summary>`;
for (const v of result.allResults) {
content += this.renderVariantBlock(v, result.winner.variantId);
}
content += `</details>`;
card.innerHTML = content;
// Attach vacation-toggle handler
const cb = card.querySelector('input[data-vacation-employee]');
if (cb) {
cb.addEventListener('change', (e) => this.onVacationToggle(e));
}
return card;
}
/**
* Render a single variant sub-panel.
*/
renderVariantBlock(v, winnerId) {
const isWinner = v.variantId === winnerId;
const star = isWinner ? '<span class="variant-badge winner">★</span>' : '';
const labels = {
1: 'V1: 1 (Fr/So) + 3 Werktage',
2: 'V2: 1 Sa + 2 Werktage',
3: 'V3 (loose): 2 qualifizierende Tage (Pool Fr+Sa+So)'
};
let thresholdStr = '-';
if (v.threshold) {
if (v.variantId === 1) thresholdStr = `Fr+So ≥ ${v.threshold.frSo}, Werktage ≥ ${v.threshold.weekday}`;
if (v.variantId === 2) thresholdStr = `Sa ≥ ${v.threshold.sa}, Werktage ≥ ${v.threshold.weekday}`;
if (v.variantId === 3) thresholdStr = `Pool ≥ ${v.threshold.pool}`;
}
const elig = v.eligible ? '<span class="variant-eligible">erfüllt</span>'
: '<span class="variant-not-eligible">nicht erfüllt</span>';
return `
<div class="variant-card${isWinner ? ' winner' : ''}">
<div class="variant-header">${star}<strong>${labels[v.variantId]}</strong></div>
<div class="variant-row"><span>Schwelle:</span><span>${thresholdStr}</span></div>
<div class="variant-row"><span>Eligibility:</span><span>${elig}</span></div>
<div class="variant-row"><span>Abzug:</span><span>
Fr ${v.deduction.fr.toFixed(2)} - Sa ${v.deduction.sa.toFixed(2)} - So ${v.deduction.so.toFixed(2)} - WT ${v.deduction.weekday.toFixed(2)}
</span></div>
<div class="variant-row"><span>Bezahlt:</span><span>
Fr ${v.paidShares.fr.toFixed(2)} - Sa ${v.paidShares.sa.toFixed(2)} - So ${v.paidShares.so.toFixed(2)} - WT ${v.paidShares.weekday.toFixed(2)}
</span></div>
<div class="variant-row variant-bonus"><span>Bonus:</span><span>${this.calculator.formatCurrency(v.bonus)}</span></div>
</div>
`;
}
/**
* Handle vacation checkbox toggle.
*/
onVacationToggle(e) {
const cb = e.target;
const name = cb.getAttribute('data-vacation-employee');
const ym = cb.getAttribute('data-vacation-yearmonth');
try {
this.storage.setVacationMode(name, ym, cb.checked);
// Re-run calc to reflect the new state
this.calculateBonuses();
} catch (err) {
this.showToast('Urlaubsmodus konnte nicht gespeichert werden', 'error');
cb.checked = !cb.checked; // revert visual state
}
}
// --- NEW: EMAIL REPORT GENERATOR ---
generateEmailReport() {
// Need to grab current selected calc month/year
@ -462,8 +624,13 @@ class DienstplanApp {
const year = parseInt(yearSelect.value);
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
const results = this.calculator.calculateAllEmployees(employeeDuties);
const yearMonth = `${year}-${String(month).padStart(2, '0')}`;
const vacationMap = {};
Object.keys(employeeDuties).forEach(n => {
vacationMap[n] = this.storage.getVacationMode(n, yearMonth);
});
const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap);
const monthName = this.getMonthName(month);
let reportHtml = `<h3>Dienstplan Abrechnung ${monthName} ${year}</h3>`;
@ -484,25 +651,26 @@ class DienstplanApp {
if (results && Object.keys(results).length > 0) {
Object.keys(results).forEach(name => {
const res = results[name];
const totalWe = res.qualifyingDays || 0;
const deducted = res.qualifyingDaysDeducted || 0;
const threshold = res.thresholdReached;
let statusText = "";
let rowStyle = "";
let blockText = "";
const w = res.winner;
const c = res.classified;
const totalWe = c.fr + c.sa + c.so;
const deducted = w.deduction.fr + w.deduction.sa + w.deduction.so;
const triggered = w.eligible && res.totalBonus > 0;
if (threshold) {
statusText = "Variante 3 (Bonus)";
rowStyle = "";
blockText = `Herr/Frau ${name} erreicht ${this.formatNumber(totalWe)} Wochenenddienste, es werden ihm/ihr ${this.formatNumber(deducted)} Wochenenddienste nicht angerechnet und somit erreicht er/sie Variante 3.`;
} else if (totalWe > 0) {
statusText = "Bonus nicht erreicht";
rowStyle = "background-color: #fff0f0;";
blockText = `Mitarbeiter ${name} erreicht das Bonussystem nicht (${this.formatNumber(totalWe)} WE-Dienste < 2.0).`;
let statusText = '';
let rowStyle = '';
let blockText = '';
if (triggered) {
statusText = `Variante ${w.variantId} (${this.calculator.formatCurrency(res.totalBonus)})${res.isVacation ? ' - Urlaub' : ''}`;
blockText = `Herr/Frau ${name} erreicht ${this.formatNumber(totalWe)} qualifizierende Dienste (Fr/Sa/So), ${this.formatNumber(deducted)} davon werden abgezogen - Bonus nach Variante ${w.variantId}: ${this.calculator.formatCurrency(res.totalBonus)}${res.isVacation ? ' (Urlaubsmodus aktiv)' : ''}.`;
} else if (totalWe > 0 || c.weekday > 0) {
statusText = 'Bonus nicht erreicht';
rowStyle = 'background-color: #fff0f0;';
blockText = `Mitarbeiter ${name} erreicht in keiner der drei Varianten die Schwelle (Fr ${c.fr.toFixed(1)}, Sa ${c.sa.toFixed(1)}, So ${c.so.toFixed(1)}, Werktage ${c.weekday.toFixed(1)})${res.isVacation ? ' - Urlaubsmodus aktiv' : ''}.`;
} else {
statusText = "-";
rowStyle = "color: #999;";
statusText = '-';
rowStyle = 'color: #999;';
}
reportHtml += `<tr style="${rowStyle}">
@ -646,39 +814,47 @@ class DienstplanApp {
// === Sheet 2: Monatliche Auswertung ===
csv += `AUSWERTUNG ${monthNames[month - 1]} ${year}\n`;
csv += 'Mitarbeiter;Normale Tage;WE/Feiertag Tage;Abzug;Normale Tage (bezahlt);WE/Feiertag (bezahlt);Schwelle erreicht;Bonus Normal;Bonus WE;Gesamtbonus (EUR)\n';
csv += 'Mitarbeiter;Urlaub;Sieger-Variante;Fr;Sa;So;Werktage;Eligible;Abzug Fr;Abzug Sa;Abzug So;Abzug WT;Bonus (EUR)\n';
const yearMonth = `${year}-${String(month).padStart(2, '0')}`;
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
const results = this.calculator.calculateAllEmployees(employeeDuties);
const vacationMap = {};
Object.keys(employeeDuties).forEach(name => {
vacationMap[name] = this.storage.getVacationMode(name, yearMonth);
});
const results = this.calculator.calculateAllEmployees(employeeDuties, vacationMap);
let totalBonus = 0;
for (const [employeeName, result] of Object.entries(results)) {
const threshold = result.thresholdReached ? 'JA' : 'NEIN';
const w = result.winner;
const c = result.classified;
totalBonus += result.totalBonus;
csv += `${escapeCSV(employeeName)};`;
csv += `${result.normalDays.toFixed(1).replace('.', ',')};`;
csv += `${result.qualifyingDays.toFixed(1).replace('.', ',')};`;
csv += `${result.qualifyingDaysDeducted.toFixed(1).replace('.', ',')};`;
csv += `${result.normalDaysPaid.toFixed(1).replace('.', ',')};`;
csv += `${result.qualifyingDaysPaid.toFixed(1).replace('.', ',')};`;
csv += `${threshold};`;
csv += `${result.bonusNormalDays.toFixed(2).replace('.', ',')};`;
csv += `${result.bonusQualifyingDays.toFixed(2).replace('.', ',')};`;
csv += `${result.isVacation ? 'JA' : 'NEIN'};`;
csv += `V${w.variantId};`;
csv += `${c.fr.toFixed(1).replace('.', ',')};`;
csv += `${c.sa.toFixed(1).replace('.', ',')};`;
csv += `${c.so.toFixed(1).replace('.', ',')};`;
csv += `${c.weekday.toFixed(1).replace('.', ',')};`;
csv += `${w.eligible ? 'JA' : 'NEIN'};`;
csv += `${w.deduction.fr.toFixed(2).replace('.', ',')};`;
csv += `${w.deduction.sa.toFixed(2).replace('.', ',')};`;
csv += `${w.deduction.so.toFixed(2).replace('.', ',')};`;
csv += `${w.deduction.weekday.toFixed(2).replace('.', ',')};`;
csv += `${result.totalBonus.toFixed(2).replace('.', ',')}\n`;
}
csv += `\nGESAMT;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`;
csv += `\nGESAMT;;;;;;;;;;;;${totalBonus.toFixed(2).replace('.', ',')}\n`;
csv += '\n\n';
csv += 'LEGENDE\n';
csv += 'Normale Tage;Montag-Donnerstag ohne Feiertag/Vortag\n';
csv += 'WE/Feiertag Tage;"Freitag, Samstag, Sonntag, Feiertag oder Tag vor Feiertag"\n';
csv += 'Schwelle;"Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich"\n';
csv += 'Sätze;"Normale Tage = 250 EUR/Einheit, WE/Feiertag = 450 EUR/Einheit"\n';
csv += 'Abzug;"Bei Erreichen der Schwelle werden 2,0 WE-Einheiten abgezogen"\n';
csv += 'Fr/Sa/So/Werktage;Klassifizierte Shares pro Slot (Halbdienste 0,5)\n';
csv += 'Sieger-Variante;V1, V2 oder V3 - automatisch die Variante mit dem höchsten Bonus\n';
csv += 'V1;"fr+so >= 1 UND weekday >= 3 (Halbiert bei Urlaub: 0,5 / 1,5)"\n';
csv += 'V2;"sa >= 1 UND weekday >= 2 (Halbiert bei Urlaub: 0,5 / 1)"\n';
csv += 'V3 (loose);"fr+sa+so >= 2 - wie bisher (Halbiert bei Urlaub: 1)"\n';
csv += 'Urlaub;"Wenn JA: Schwellen und Abzüge halbiert"\n';
csv += 'Sätze;"Werktag = 250 EUR/Einheit, Fr/Sa/So/Feiertag = 450 EUR/Einheit"\n';
// Download CSV file
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
@ -730,30 +906,18 @@ class DienstplanApp {
for (const [name, duties] of Object.entries(employeeDuties)) {
employeeData[name] = {
duties: duties,
byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] },
wt: 0,
we_fr: 0,
we_other: 0
byWeekday: { 0: [], 1: [], 2: [], 3: [], 4: [], 5: [], 6: [] }
};
duties.forEach(duty => {
const dayOfWeek = duty.date.getDay();
const isQualifying = this.calculator.isQualifyingDay(duty.date);
const isFriday = dayOfWeek === 5;
employeeData[name].byWeekday[dayOfWeek].push({
...duty,
isQual: isQualifying,
dayType: this.calculator.getDayTypeLabel(duty.date)
});
if (!isQualifying) {
employeeData[name].wt += duty.share;
} else if (isFriday) {
employeeData[name].we_fr += duty.share;
} else {
employeeData[name].we_other += duty.share;
}
});
}
@ -888,54 +1052,37 @@ class DienstplanApp {
let totalBonus = 0;
const employeeNotes = [];
// Compute via BonusCalculator (uses winning variant)
const yearMonth = `${year}-${String(month).padStart(2, '0')}`;
const vacationMap = {};
Object.keys(employeeDuties).forEach(n => {
vacationMap[n] = this.storage.getVacationMode(n, yearMonth);
});
const calcResults = this.calculator.calculateAllEmployees(employeeDuties, vacationMap);
for (const [name, data] of Object.entries(employeeData)) {
const we_total = data.we_fr + data.we_other;
const thresholdReached = we_total >= this.calculator.MIN_QUALIFYING_DAYS - 0.0001;
let bonus = 0;
if (thresholdReached) {
const wt_pay = data.wt * this.calculator.RATE_NORMAL;
let deduct = this.calculator.DEDUCTION_AMOUNT;
let deduct_fr = Math.min(deduct, data.we_fr);
let deduct_other = Math.max(0, deduct - deduct_fr);
const paid_fr = Math.max(0, data.we_fr - deduct_fr);
const paid_other = Math.max(0, data.we_other - deduct_other);
const we_pay = (paid_fr + paid_other) * this.calculator.RATE_WEEKEND;
bonus = wt_pay + we_pay;
}
const calcRes = calcResults[name] || this.calculator.getEmptyResult();
const bonus = calcRes.totalBonus;
const w = calcRes.winner;
totalBonus += bonus;
// Generate note - cleaner, more professional format
const safeName = escapeHtml(name);
let note = '';
if (!thresholdReached) {
note = `<b>${safeName}</b> erreicht die Mindestschwelle nicht (${we_total.toFixed(1)} von ${this.calculator.MIN_QUALIFYING_DAYS.toFixed(1)} WE-Einheiten) und erhält daher keine Bonuszahlung.`;
if (bonus === 0 || !w.eligible) {
note = `<b>${safeName}</b> erreicht in keiner der drei Varianten einen positiven Bonus${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''} und erhält daher keine Bonuszahlung.`;
} else {
const paid_we = we_total - this.calculator.DEDUCTION_AMOUNT;
let breakdown = [];
if (data.wt > 0) breakdown.push(`${data.wt.toFixed(1)} WT-Einheiten à ${this.calculator.RATE_NORMAL}`);
if (paid_we > 0) breakdown.push(`${paid_we.toFixed(1)} WE-Einheiten à ${this.calculator.RATE_WEEKEND}`);
note = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.calculator.formatCurrency(bonus)}</span>`;
if (breakdown.length > 0) {
note += ` (${breakdown.join(' + ')})`;
}
note += '.';
const c = calcRes.classified;
note = `<b>${safeName}</b> erhält eine Bonuszahlung von <span style="color: #28a745; font-weight: bold;">${this.calculator.formatCurrency(bonus)}</span> nach Variante ${w.variantId}${calcRes.isVacation ? ' (Urlaubsmodus aktiv)' : ''}. Klassifiziert: Fr ${c.fr.toFixed(1)} / Sa ${c.sa.toFixed(1)} / So ${c.so.toFixed(1)} / Werktage ${c.weekday.toFixed(1)}.`;
}
employeeNotes.push(note);
// Build table row
html += `
<tr>
<td class="employee-name">${safeName}</td>`;
// Days: Mo(1), Di(2), Mi(3), Do(4), Fr(5), Sa(6), So(0)
const dayOrder = [1, 2, 3, 4, 5, 6, 0];
for (const dayIdx of dayOrder) {
const dayDuties = data.byWeekday[dayIdx];
if (dayDuties.length === 0) {
@ -944,16 +1091,12 @@ class DienstplanApp {
let cellContent = '';
dayDuties.forEach(duty => {
const shareStr = duty.share === 0.5 ? '½' : '';
// Determine tag style
let tag = duty.isQual ? 'we-tag' : 'wt-tag';
// Build cell content
const tag = duty.isQual ? 'we-tag' : 'wt-tag';
cellContent += `<span class="${tag}">${shareStr}X</span><br>`;
});
html += `<td class="duty-cell">${cellContent}</td>`;
}
}
html += `
<td class="${bonus > 0 ? 'bonus-amount' : 'no-bonus'}">${bonus > 0 ? this.calculator.formatCurrency(bonus) : '-'}</td>
</tr>`;
@ -976,22 +1119,20 @@ class DienstplanApp {
html += `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd;">
<p><strong>Berechnungsregeln (Variante 2 - Streng):</strong></p>
<p><strong>Berechnungsregeln (NRW Psychiatrie 2011):</strong></p>
<ul>
<li><strong>WE-Tage:</strong> Freitag, Samstag, Sonntag, Feiertage und Tage vor Feiertagen</li>
<li><strong>Schwelle:</strong> Mindestens 2,0 WE-Einheiten für Bonuszahlung erforderlich</li>
<li><strong>Vergütung bei Erreichen der Schwelle:</strong>
<ul>
<li>Werktage (WT): 250 pro Einheit</li>
<li>WE-Tage: 450 pro Einheit (abzüglich 2,0 Einheiten Abzug, Freitag zuerst)</li>
</ul>
</li>
<li><strong>Unter Schwelle:</strong> Keine Bonuszahlung (weder WT noch WE)</li>
<li><strong>Slots:</strong> Jeder Dienst wird in fr / sa / so / werktag klassifiziert. Tag vor Mo-Do-Feiertag = fr. Mo-Do-Feiertag = so. Sandwich-Tag (Feiertag + Tag-vor) = sa.</li>
<li><strong>V1:</strong> fr+so 1 UND werktag 3 Abzug 1 (Fr-Prio) + 3 werktag.</li>
<li><strong>V2:</strong> sa 1 UND werktag 2 Abzug 1 sa + 2 werktag.</li>
<li><strong>V3 (loose):</strong> fr+sa+so 2 Abzug 2 aus Pool (Prio fr so sa).</li>
<li><strong>Auto-Select:</strong> Die Variante mit dem höchsten Bonus gewinnt; bei Gleichstand gewinnt die niedrigste Variantennummer.</li>
<li><strong>Urlaubsmodus (14 Tage frei):</strong> Halbiert alle Schwellen UND Abzüge.</li>
<li><strong>Sätze:</strong> Werktag = 250 EUR, Fr/Sa/So/Feiertag = 450 EUR.</li>
</ul>
</div>
<p style="margin-top: 30px; color: #666; font-size: 0.9em;">
Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan NRW (Variante 2 - Streng)
Erstellt am: ${new Date().toLocaleDateString('de-DE')} | Dienstplan-Pro - NRW Psychiatrie 2011
</p>
</body>

View file

@ -1,41 +1,26 @@
/**
* Duty Schedule Bonus Calculator
* Calculates bonuses based on weekend and holiday duty shifts
* Bonus Calculator (NRW Psychiatrie 2011)
* Orchestrator: classifies duties, runs all three variants (V1/V2/V3), picks the winner.
* Pure variant logic lives in variants.js.
*/
class BonusCalculator {
constructor(holidayProvider) {
this.holidayProvider = holidayProvider;
this.RATE_NORMAL = 250; // Normal day rate (not weekend/holiday)
this.RATE_WEEKEND = 450; // Weekend/holiday rate
this.MIN_QUALIFYING_DAYS = 2.0; // Minimum qualifying days to trigger bonus
this.DEDUCTION_AMOUNT = 2.0; // Deduction after reaching threshold
this.RATE_NORMAL = 250;
this.RATE_WEEKEND = 450;
}
/**
* Check if a date is a qualifying day (weekend or holiday related)
* Qualifying days: Friday, Saturday, Sunday, Public Holiday, Day before public holiday
* @param {Date} date
* @returns {boolean}
* Whether the given date is a "qualifying" day (used by UI for badge coloring).
* Mirrors the old isQualifyingDay so app.js does not break.
*/
isQualifyingDay(date) {
const dayOfWeek = date.getDay(); // 0 = Sunday, 5 = Friday, 6 = Saturday
// Weekend: Friday (5), Saturday (6), Sunday (0)
const isWeekend = dayOfWeek === 5 || dayOfWeek === 6 || dayOfWeek === 0;
// Public holiday
const isHoliday = this.holidayProvider.isHoliday(date);
// Day before public holiday
const isDayBeforeHoliday = this.holidayProvider.isDayBeforeHoliday(date);
return isWeekend || isHoliday || isDayBeforeHoliday;
const slot = classify(date, this.holidayProvider);
return slot !== 'weekday';
}
/**
* Get day type label for display
* @param {Date} date
* @returns {string}
* Human-readable label for the date's day type (used by UI).
*/
getDayTypeLabel(date) {
const dayOfWeek = date.getDay();
@ -43,12 +28,8 @@ class BonusCalculator {
const holidayName = this.holidayProvider.getHolidayName(date);
const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date);
if (isHoliday) {
return `Feiertag (${holidayName})`;
}
if (isDayBefore) {
return 'Tag vor Feiertag';
}
if (isHoliday) return `Feiertag (${holidayName})`;
if (isDayBefore) return 'Tag vor Feiertag';
if (dayOfWeek === 5) return 'Freitag';
if (dayOfWeek === 6) return 'Samstag';
if (dayOfWeek === 0) return 'Sonntag';
@ -58,143 +39,94 @@ class BonusCalculator {
}
/**
* Calculate bonus for a single employee for a given month
* @param {Array} duties - Array of duty objects: {date: Date, share: number (1.0 or 0.5)}
* @returns {Object} Calculation result
* Build the dutyDetails array (date, share, isQualifying, dayType) for the UI.
*/
calculateMonthlyBonus(duties) {
buildDutyDetails(duties) {
return duties.map(duty => ({
date: duty.date,
share: duty.share,
isQualifying: this.isQualifyingDay(duty.date),
dayType: this.getDayTypeLabel(duty.date)
}));
}
/**
* Calculate the bonus for a single employee for a given month.
* @param {Array} duties - Array of { date: Date, share: number }
* @param {boolean} isVacation - Vacation toggle (halves thresholds + deductions)
* @returns {Object} new-shape result (winner, allResults, totalBonus, classified, isVacation, dutyDetails)
*/
calculateMonthlyBonus(duties, isVacation = false) {
if (!duties || duties.length === 0) {
return this.getEmptyResult();
return this.getEmptyResult(isVacation);
}
// Separate qualifying days (Friday separate from others) and non-qualifying days
let qualifyingDaysFriday = 0;
let qualifyingDaysOther = 0;
let normalDays = 0;
const dutyDetails = [];
const classified = classifyDuties(duties, this.holidayProvider);
const v1 = variant1(classified, isVacation);
const v2 = variant2(classified, isVacation);
const v3 = variant3(classified, isVacation);
const results = [v1, v2, v3];
duties.forEach(duty => {
const isQualifying = this.isQualifyingDay(duty.date);
const dayType = this.getDayTypeLabel(duty.date);
const isFriday = duty.date.getDay() === 5;
if (isQualifying) {
if (isFriday) {
qualifyingDaysFriday += duty.share;
} else {
qualifyingDaysOther += duty.share;
}
} else {
normalDays += duty.share;
// Pick winner: highest bonus; on tie prefer eligible over ineligible;
// further tie-break = lowest variantId (strict > preserves it).
let winner = results[0];
for (let i = 1; i < results.length; i++) {
const r = results[i];
if (r.bonus > winner.bonus) {
winner = r;
} else if (r.bonus === winner.bonus && r.eligible && !winner.eligible) {
winner = r;
}
dutyDetails.push({
date: duty.date,
share: duty.share,
isQualifying: isQualifying,
dayType: dayType
});
});
const qualifyingDaysTotal = qualifyingDaysFriday + qualifyingDaysOther;
// Check if threshold is reached
const thresholdReached = qualifyingDaysTotal >= this.MIN_QUALIFYING_DAYS;
let bonus = 0;
let normalDaysPaid = 0;
let qualifyingDaysPaid = 0;
let deductionFromFriday = 0;
let deductionFromOther = 0;
let totalDeduction = 0;
if (thresholdReached) {
// Deduct qualifying days with Friday priority
totalDeduction = this.DEDUCTION_AMOUNT;
// First deduct from Friday
deductionFromFriday = Math.min(totalDeduction, qualifyingDaysFriday);
// Remaining deduction from other qualifying days
deductionFromOther = Math.max(0, totalDeduction - deductionFromFriday);
// Calculate paid days
const qualifyingDaysFridayPaid = Math.max(0, qualifyingDaysFriday - deductionFromFriday);
const qualifyingDaysOtherPaid = Math.max(0, qualifyingDaysOther - deductionFromOther);
qualifyingDaysPaid = qualifyingDaysFridayPaid + qualifyingDaysOtherPaid;
normalDaysPaid = normalDays;
// Calculate bonus
bonus = (normalDaysPaid * this.RATE_NORMAL) + (qualifyingDaysPaid * this.RATE_WEEKEND);
}
// If threshold not reached: no bonus paid (neither WT nor WE)
winner.isWinner = true;
return {
classified,
isVacation,
winner,
allResults: results,
totalBonus: winner.bonus,
totalDuties: duties.length,
totalDaysWorked: qualifyingDaysTotal + normalDays,
normalDays: normalDays,
qualifyingDaysFriday: qualifyingDaysFriday,
qualifyingDaysOther: qualifyingDaysOther,
qualifyingDays: qualifyingDaysTotal,
thresholdReached: thresholdReached,
deductionFromFriday: deductionFromFriday,
deductionFromOther: deductionFromOther,
qualifyingDaysDeducted: totalDeduction,
normalDaysPaid: normalDaysPaid,
qualifyingDaysPaid: qualifyingDaysPaid,
bonusNormalDays: normalDaysPaid * this.RATE_NORMAL,
bonusQualifyingDays: qualifyingDaysPaid * this.RATE_WEEKEND,
totalBonus: bonus,
dutyDetails: dutyDetails
dutyDetails: this.buildDutyDetails(duties)
};
}
/**
* Calculate bonuses for all employees
* @param {Object} employeeDuties - Object with employee names as keys and duty arrays as values
* @returns {Object} Results for all employees
* Calculate for all employees. vacationMap: { [employeeName]: boolean }
*/
calculateAllEmployees(employeeDuties) {
calculateAllEmployees(employeeDuties, vacationMap = {}) {
const results = {};
for (const [employeeName, duties] of Object.entries(employeeDuties)) {
results[employeeName] = this.calculateMonthlyBonus(duties);
for (const [name, duties] of Object.entries(employeeDuties)) {
const isVac = Boolean(vacationMap[name]);
results[name] = this.calculateMonthlyBonus(duties, isVac);
}
return results;
}
/**
* Get empty result structure
* @returns {Object}
*/
getEmptyResult() {
getEmptyResult(isVacation = false) {
const empty = {
variantId: 1,
eligible: false,
threshold: null,
deduction: { fr: 0, sa: 0, so: 0, weekday: 0 },
paidShares: { fr: 0, sa: 0, so: 0, weekday: 0 },
bonus: 0,
isWinner: true
};
return {
totalDuties: 0,
totalDaysWorked: 0,
normalDays: 0,
qualifyingDaysFriday: 0,
qualifyingDaysOther: 0,
qualifyingDays: 0,
thresholdReached: false,
deductionFromFriday: 0,
deductionFromOther: 0,
qualifyingDaysDeducted: 0,
normalDaysPaid: 0,
qualifyingDaysPaid: 0,
bonusNormalDays: 0,
bonusQualifyingDays: 0,
classified: { fr: 0, sa: 0, so: 0, weekday: 0 },
isVacation,
winner: empty,
allResults: [empty,
{ ...empty, variantId: 2, isWinner: false },
{ ...empty, variantId: 3, isWinner: false }
],
totalBonus: 0,
totalDuties: 0,
dutyDetails: []
};
}
/**
* Format currency for display
* @param {number} amount
* @returns {string}
*/
formatCurrency(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',

View file

@ -72,7 +72,11 @@
<!-- Add Duty Form -->
<div class="form-group">
<label for="duty-date">Datum:</label>
<input type="date" id="duty-date">
<div class="date-stepper">
<button type="button" id="duty-date-prev" class="btn btn-secondary" aria-label="Vorheriger Tag">&lsaquo;</button>
<input type="date" id="duty-date">
<button type="button" id="duty-date-next" class="btn btn-secondary" aria-label="Nächster Tag">&rsaquo;</button>
</div>
</div>
<div class="form-group">
@ -163,26 +167,44 @@
<h2>Einstellungen & Daten</h2>
<div class="settings-section">
<h3>Berechnungsregeln</h3>
<h3>Berechnungsregeln (NRW Psychiatrie 2011)</h3>
<div class="info-box">
<h4>Qualifizierende Tage (WE/Feiertag):</h4>
<h4>Tag-Klassifizierung (Slot pro Dienst):</h4>
<ul>
<li>Freitag, Samstag, Sonntag</li>
<li>Feiertage in NRW</li>
<li>Tag vor einem Feiertag</li>
<li><strong>fr</strong>: Freitag &middot; oder Tag vor einem Mo-Do-Feiertag</li>
<li><strong>sa</strong>: Samstag &middot; oder Sandwich-Tag (Feiertag UND Tag vor Feiertag, z. B. Do Feiertag + Fr Feiertag &rarr; Do = sa)</li>
<li><strong>so</strong>: Sonntag &middot; oder Mo-Do-Feiertag (ohne Sandwich)</li>
<li><strong>weekday</strong>: Mo-Do ohne Feiertag und ohne Tag-vor-Feiertag</li>
</ul>
<h4>Bonusberechnung:</h4>
<h4>Drei Varianten (es gewinnt die mit dem h&ouml;chsten Bonus):</h4>
<ul>
<li>Mindestens <strong>2.0 qualifizierende Tage</strong> erforderlich</li>
<li>Bei Erreichen der Schwelle: <strong>1.0 qualifizierender Tag</strong> wird abgezogen</li>
<li>Normale Tage: <strong>250€</strong> pro Tag</li>
<li>Qualifizierende Tage: <strong>450€</strong> pro Tag</li>
<li>Halbe Dienste werden mit der Hälfte berechnet</li>
<li><strong>V1:</strong> fr+so &ge; 1 UND weekday &ge; 3 &rarr; Abzug 1 aus fr+so (Fr-Prio) und 3 aus weekday. sa wird voll bezahlt.</li>
<li><strong>V2:</strong> sa &ge; 1 UND weekday &ge; 2 &rarr; Abzug 1 sa und 2 weekday. fr und so werden voll bezahlt.</li>
<li><strong>V3 (loose):</strong> fr+sa+so &ge; 2 &rarr; Abzug 2 aus Pool, Priorit&auml;t fr &rarr; so &rarr; sa. weekday wird voll bezahlt.</li>
</ul>
<h4>Wichtig:</h4>
<p>Wenn weniger als 2.0 qualifizierende Tage erreicht werden, erfolgt <strong>keine Bonuszahlung</strong>.</p>
<h4>Auto-Selection und Tie-Breaker:</h4>
<p>Es wird die Variante mit dem h&ouml;chsten Bonus ausgew&auml;hlt. Bei Gleichstand gewinnt die niedrigste Variantennummer (V1 &lt; V2 &lt; V3).</p>
<h4>Urlaubsmodus (&ge;14 Tage frei):</h4>
<p>Toggle pro Mitarbeiter und Monat. Halbiert <strong>alle</strong> Schwellen UND Abz&uuml;ge. Halbe Werte sind explizit erlaubt.</p>
<h4>S&auml;tze:</h4>
<ul>
<li>weekday: <strong>250&nbsp;&euro;</strong> pro Einheit</li>
<li>fr / sa / so: <strong>450&nbsp;&euro;</strong> pro Einheit</li>
<li>Halbdienste werden mit 0.5 gerechnet</li>
</ul>
<h4>Beispiele Tag-Klassifizierung:</h4>
<ul>
<li>Karfreitag (Fr): fr (Wochentag gewinnt)</li>
<li>Ostermontag (Mo-Feiertag): so</li>
<li>Christi Himmelfahrt (Do-Feiertag): so</li>
<li>Mittwoch vor Christi Himmelfahrt: fr</li>
<li>Tag der Deutschen Einheit 2025 (Fr): fr</li>
</ul>
</div>
</div>
@ -301,6 +323,7 @@
<!-- Scripts -->
<script src="holidays.js"></script>
<script src="variants.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="app.js"></script>

View file

@ -6,6 +6,7 @@ class DataStorage {
constructor() {
this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
this.STORAGE_KEY_VACATION = 'dienstplan_vacation';
this.STORAGE_KEY_OPENROUTER_KEY = 'dienstplan_openrouter_key';
this.STORAGE_KEY_OPENROUTER_MODEL = 'dienstplan_openrouter_model';
this.DEFAULT_MODEL = 'anthropic/claude-sonnet-4.6';
@ -263,12 +264,64 @@ class DataStorage {
return result;
}
/**
* 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 {};
}
}
/**
* Clear all data
*/
clearAll() {
localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES);
localStorage.removeItem(this.STORAGE_KEY_DUTIES);
localStorage.removeItem(this.STORAGE_KEY_VACATION);
}
/**
@ -279,7 +332,8 @@ class DataStorage {
try {
return JSON.stringify({
employees: this.getEmployees(),
duties: this.getAllDuties()
duties: this.getAllDuties(),
vacation: this.getAllVacationModes()
}, null, 2);
} catch (e) {
console.error('Fehler beim Exportieren der Daten:', e);
@ -299,11 +353,12 @@ class DataStorage {
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);

View file

@ -768,3 +768,151 @@ header h1 {
.api-key-status-ok { color: #28a745; font-weight: 500; }
.api-key-status-none { color: #6c757d; font-style: italic; }
/* === Variants UI === */
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.vacation-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: #fff;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
user-select: none;
}
.vacation-toggle input[type="checkbox"] {
margin: 0;
cursor: pointer;
}
.vacation-active-banner {
background: #fff3cd;
border-left: 4px solid #ffc107;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 12px;
color: #856404;
font-size: 0.9rem;
}
.classified-summary {
display: flex;
gap: 20px;
flex-wrap: wrap;
padding: 10px 15px;
background: #f8f9fa;
border-radius: 6px;
margin: 12px 0;
font-size: 0.9rem;
}
.variant-details {
margin-top: 15px;
background: #f8f9fa;
border-radius: 6px;
padding: 10px 15px;
}
.variant-details summary {
cursor: pointer;
font-weight: 500;
color: #667eea;
padding: 4px 0;
}
.variant-card {
background: white;
border: 1px solid #e0e0e0;
border-radius: 6px;
padding: 12px 15px;
margin: 10px 0;
}
.variant-card.winner {
border-color: #28a745;
box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.15);
}
.variant-header {
margin-bottom: 8px;
font-size: 0.95rem;
}
.variant-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
margin-right: 6px;
background: #28a745;
color: white;
font-weight: 600;
}
.variant-row {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 4px 0;
font-size: 0.85rem;
color: #555;
border-top: 1px solid #f0f0f0;
}
.variant-row:first-of-type {
border-top: none;
}
.variant-bonus {
font-weight: 600;
color: #333;
font-size: 0.95rem;
}
.variant-eligible {
color: #28a745;
font-weight: 600;
}
.variant-not-eligible {
color: #dc3545;
font-weight: 600;
}
/* === Date Stepper === */
.date-stepper {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 6px;
align-items: stretch;
}
.date-stepper input[type="date"] {
/* override the .form-group width */
width: 100%;
}
.date-stepper button {
padding: 0 14px;
margin: 0;
font-size: 1.2rem;
line-height: 1;
min-width: 44px;
}
.date-stepper button:disabled {
opacity: 0.4;
cursor: not-allowed;
}

3
sw.js
View file

@ -1,10 +1,11 @@
const CACHE_NAME = 'dienstplan-pro-v3';
const CACHE_NAME = 'dienstplan-pro-v4';
const ASSETS = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./calculator.js',
'./variants.js',
'./holidays.js',
'./storage.js',
'./image-import.js'

View file

@ -173,145 +173,120 @@ runner.test('Calculator: Tag vor Feiertag ist qualifizierender Tag', (t) => {
});
// ============================================================================
// Calculator Tests - Bonus Calculation
// Calculator Tests - Bonus Calculation (new variants shape)
// ============================================================================
runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0', (t) => {
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);
t.assertEqual(result.qualifyingDays, 1.0, 'Sollte 1.0 qualifizierende Tage haben');
t.assertFalse(result.thresholdReached, 'Schwellenwert sollte nicht erreicht sein');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
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 = 0€', (t) => {
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);
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben');
t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein');
t.assertEqual(result.qualifyingDaysDeducted, 2.0, 'Sollte 2.0 Tage abziehen');
t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
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: 2x halbe WE-Dienste = 0€ (genau Schwelle, nach Abzug 2.0)', (t) => {
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 }, // Halber Samstag
{ date: new Date('2025-11-22T12:00:00'), share: 0.5 }, // Halber Samstag
{ date: new Date('2025-11-23T12:00:00'), share: 0.5 }, // Halber Sonntag
{ date: new Date('2025-11-23T12:00:00'), share: 0.5 } // Halber Sonntag
{ 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);
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben (4×0.5)');
t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein');
t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 Tage bezahlen nach Abzug');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
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 = 450€', (t) => {
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);
t.assertEqual(result.qualifyingDays, 3.0, 'Sollte 3.0 qualifizierende Tage haben');
t.assertEqual(result.qualifyingDaysPaid, 1.0, 'Sollte 1.0 Tage bezahlen (3-2)');
t.assertEqual(result.totalBonus, 450, 'Bonus sollte 450€ sein (1×450€)');
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', (t) => {
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 (normal)
{ date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Dienstag (normal)
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Samstag (qualifizierend)
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 } // Sonntag (qualifizierend)
{ 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);
t.assertEqual(result.normalDays, 2.0, 'Sollte 2.0 normale Tage haben');
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben');
t.assertEqual(result.normalDaysPaid, 2.0, 'Sollte 2.0 normale Tage bezahlen');
t.assertEqual(result.qualifyingDaysPaid, 0.0, 'Sollte 0.0 qualifizierende Tage bezahlen');
t.assertEqual(result.bonusNormalDays, 500, 'Normale Tage: 2×250€ = 500€');
t.assertEqual(result.bonusQualifyingDays, 0, 'WE-Tage: 0×450€ = 0€');
t.assertEqual(result.totalBonus, 500, 'Gesamt: 500€');
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 berechnet', (t) => {
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 Montag
{ date: new Date('2025-11-22T12:00:00'), share: 0.5 }, // Halber Samstag
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // Ganzer Sonntag
{ date: new Date('2025-11-21T12:00:00'), share: 1.0 } // Ganzer Freitag
{ 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);
t.assertEqual(result.normalDays, 0.5, 'Sollte 0.5 normale Tage haben');
t.assertEqual(result.qualifyingDays, 2.5, 'Sollte 2.5 qualifizierende Tage haben');
t.assertEqual(result.qualifyingDaysPaid, 0.5, 'Sollte 0.5 qualifizierende Tage bezahlen');
t.assertEqual(result.bonusNormalDays, 125, 'Normale Tage: 0.5×250€ = 125€');
t.assertEqual(result.bonusQualifyingDays, 225, 'WE-Tage: 0.5×450€ = 225€');
t.assertEqual(result.totalBonus, 350, 'Gesamt: 350€');
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 + Vortag', (t) => {
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 }, // Mittwoch vor 1. Mai (qualifizierend)
{ date: new Date('2025-05-01T12:00:00'), share: 1.0 } // 1. Mai (Feiertag, qualifizierend)
{ 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);
t.assertEqual(result.qualifyingDays, 2.0, 'Sollte 2.0 qualifizierende Tage haben');
t.assertTrue(result.thresholdReached, 'Schwellenwert sollte erreicht sein');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein (2.0 - 2.0 = 0.0 × 450€)');
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', (t) => {
runner.test('Berechnung: Keine Dienste = 0 EUR', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const result = calculator.calculateMonthlyBonus([]);
t.assertEqual(result.totalDuties, 0, 'Sollte 0 Dienste haben');
t.assertEqual(result.totalBonus, 0, 'Bonus sollte 0€ sein');
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');
});
// ============================================================================
@ -418,6 +393,77 @@ runner.test('Storage: Export und Import', (t) => {
t.assertEqual(duties.length, 1, 'Sollte 1 Dienst haben');
});
// ============================================================================
// 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');
});
// ============================================================================
// Edge Cases & Regression Tests
// ============================================================================
@ -425,25 +471,21 @@ runner.test('Storage: Export und Import', (t) => {
runner.test('Edge Case: Exakt Schwellenwert mit Rundungsfehler (1.9999)', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
// Simuliere Rundungsfehler
const duties = [
{ date: new Date('2025-11-22T12:00:00'), share: 0.66666 },
{ date: new Date('2025-11-23T12:00:00'), share: 0.66666 },
{ date: new Date('2025-11-21T12:00:00'), share: 0.66666 }
{ 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);
// 0.66666 × 3 ≈ 1.99998, sollte als >= 2.0 gelten
t.assertTrue(result.thresholdReached || result.qualifyingDays < 2.0,
'Sollte Rundung korrekt handhaben');
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');
});
runner.test('Edge Case: Sehr viele Dienste (Performance)', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const duties = [];
for (let i = 1; i <= 30; i++) {
duties.push({
@ -451,26 +493,441 @@ runner.test('Edge Case: Sehr viele Dienste (Performance)', (t) => {
share: i % 2 === 0 ? 1.0 : 0.5
});
}
const start = Date.now();
const result = calculator.calculateMonthlyBonus(duties);
const result = calculator.calculateMonthlyBonus(duties, false);
const duration = Date.now() - start;
t.assertTrue(duration < 100, `Berechnung sollte schnell sein (${duration}ms)`);
t.assertTrue(result.totalBonus > 0, 'Sollte Bonus berechnen');
t.assertTrue(duration < 100, `Berechnung schnell (${duration}ms)`);
t.assertTrue(result.totalBonus > 0, 'Bonus > 0');
});
runner.test('Edge Case: Dienst am 29. Februar (Schaltjahr)', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const duties = [
{ date: new Date('2028-02-29T12:00:00'), share: 1.0 } // Dienstag (nicht qualifizierend)
{ 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');
});
// Sollte nicht crashen
const result = calculator.calculateMonthlyBonus(duties);
t.assertEqual(result.normalDays, 1.0, 'Sollte normalen Tag erkennen');
// ============================================================================
// 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');
});
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');
});
// ============================================================================
// 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');
});
// ============================================================================
// 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');
});
// ============================================================================
// 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');
});
// ============================================================================
// BonusCalculator - Winner Selection (new shape)
// ============================================================================
runner.test('Winner: klarer Sieger mit weekdays + 1 Fr', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
const duties = [
{ date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
{ date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di
{ date: new Date('2025-11-26T12:00:00'), share: 1.0 }, // Mi
{ date: new Date('2025-11-27T12:00:00'), share: 1.0 }, // Do
{ date: new Date('2025-11-04T12:00:00'), share: 1.0 } // Di
];
const result = calc.calculateMonthlyBonus(duties, false);
t.assertTrue(result.winner.isWinner, 'winner.isWinner=true');
t.assertEqual(result.allResults.length, 3, '3 Varianten im allResults');
t.assertTrue(result.totalBonus > 0, 'Bonus > 0');
});
runner.test('Winner: klarer V3-Sieger (nur WE-Dienste)', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
const duties = [
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 }, // Sa
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 }, // So
{ date: new Date('2025-11-29T12:00:00'), share: 1.0 } // Sa
];
const result = calc.calculateMonthlyBonus(duties, false);
// V1: fr+so=1, weekday=0 -> not eligible
// V2: sa=2, weekday=0 -> not eligible
// V3: pool=3 -> eligible, deduction 2 (fr=0,so=1 abgezogen, sa=1 abgezogen) -> 1 sa paid -> 450
t.assertEqual(result.winner.variantId, 3, 'V3 muss Sieger sein');
t.assertEqual(result.totalBonus, 450, 'bonus=450');
});
runner.test('Winner: Tie-Breaker - alle three not eligible -> V1 nominal winner, totalBonus 0', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
// fr=1, sa=0, so=0, weekday=3:
// V1: fr+so=1 ok, weekday=3 ok -> eligible. Abzug fr=1, weekday=3 -> alles weg, bonus 0.
// V2: sa=0 -> not eligible (0).
// V3: pool=1 < 2 -> not eligible (0).
// -> tie at 0; V1 has eligible=true so its result is still 0. Strict > keeps v1 as winner.
const duties = [
{ date: new Date('2025-11-21T12:00:00'), share: 1.0 }, // Fr
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
{ date: new Date('2025-11-25T12:00:00'), share: 1.0 }, // Di
{ date: new Date('2025-11-26T12:00:00'), share: 1.0 } // Mi
];
const result = calc.calculateMonthlyBonus(duties, false);
t.assertEqual(result.winner.variantId, 1, 'V1 wins tie (lowest variantId)');
t.assertEqual(result.totalBonus, 0, 'totalBonus=0 (all-zero tie)');
});
runner.test('Winner: nur V3 produziert positive bonus -> V3 winner', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
// Three Saturdays: V1 not eligible, V2 not eligible (weekday=0), V3 eligible with positive bonus.
const duties = [
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 },
{ date: new Date('2025-11-29T12:00:00'), share: 1.0 },
{ date: new Date('2025-11-15T12:00:00'), share: 1.0 }
];
const result = calc.calculateMonthlyBonus(duties, false);
// V3: pool=3, abzug 2 (so=0, fr=0, sa=2 abgezogen) -> 1 sa paid -> 450
t.assertEqual(result.winner.variantId, 3, 'V3 winner');
t.assertEqual(result.totalBonus, 450, 'bonus=450');
});
runner.test('Winner: result-Shape enthaelt classified, isVacation, dutyDetails', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
const duties = [
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 },
{ date: new Date('2025-11-23T12:00:00'), share: 1.0 }
];
const result = calc.calculateMonthlyBonus(duties, false);
t.assertTrue('classified' in result, 'classified field exists');
t.assertTrue('isVacation' in result, 'isVacation field exists');
t.assertTrue('dutyDetails' in result, 'dutyDetails field exists');
t.assertEqual(result.dutyDetails.length, 2, 'dutyDetails has 2 entries');
t.assertEqual(result.isVacation, false, 'isVacation=false');
});
runner.test('Winner: Urlaubsmodus halbiert alle Schwellen', (t) => {
const hp = new HolidayProvider();
const calc = new BonusCalculator(hp);
// fr=0.5, weekday=1.5 -> V1 eligible im Urlaub (0.5 >= 0.5, 1.5 >= 1.5)
const duties = [
{ date: new Date('2025-11-21T12:00:00'), share: 0.5 }, // Fr
{ date: new Date('2025-11-24T12:00:00'), share: 1.0 }, // Mo
{ date: new Date('2025-11-25T12:00:00'), share: 0.5 } // Di
];
const result = calc.calculateMonthlyBonus(duties, true);
t.assertEqual(result.isVacation, true, 'isVacation propagated');
t.assertEqual(result.winner.variantId, 1, 'V1 wins under vacation');
});
// ============================================================================

View file

@ -140,6 +140,7 @@
<div id="test-results"></div>
<script src="holidays.js"></script>
<script src="variants.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="image-import.js"></script>

194
variants.js Normal file
View file

@ -0,0 +1,194 @@
/**
* Bonus-Varianten (NRW Psychiatrie 2011)
* Pure functions: day classification + V1/V2/V3 evaluation.
* Loaded after holidays.js and before calculator.js.
*/
// Will be implemented in subsequent tasks.
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';
}
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;
}
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
};
}
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
};
}
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
};
}
// Expose globally
window.classify = classify;
window.classifyDuties = classifyDuties;
window.variant1 = variant1;
window.variant2 = variant2;
window.variant3 = variant3;