feat: Unified PWA release

This commit is contained in:
OpenClaw Agent 2026-02-02 11:51:11 +00:00
commit 7116eb3466
13 changed files with 3537 additions and 0 deletions

10
Dockerfile Normal file
View file

@ -0,0 +1,10 @@
FROM nginx:alpine
# Copy static assets to Nginx html folder
COPY . /usr/share/nginx/html
# Expose port 80
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

164
README.md Normal file
View file

@ -0,0 +1,164 @@
# Dienstplan Bonusrechner - Web App
Eine Web-Anwendung zur Berechnung von Bonuszahlungen für Wochenend- und Feiertagsdienste nach NRW-Regeln.
## Features
- ✅ **Mitarbeiterverwaltung**: Mehrere Mitarbeiter gleichzeitig verwalten
- ✅ **Dienstplanung**: Dienste für beliebige Monate eintragen (ganze und halbe Dienste)
- ✅ **Automatische Feiertagserkennung**: NRW-Feiertage 2025-2030
- ✅ **Bonusberechnung**: Automatische Berechnung nach festgelegten Regeln
- ✅ **Datenexport/Import**: JSON-Export für Backup und Migration
- ✅ **LocalStorage**: Alle Daten werden lokal im Browser gespeichert
- ✅ **Responsive Design**: Funktioniert auf Desktop und Mobilgeräten
## Berechnungsregeln
### Qualifizierende Tage (WE/Feiertag)
- **Wochenende**: Freitag, Samstag, Sonntag
- **Feiertage**: Alle gesetzlichen Feiertage in NRW
- **Tag vor Feiertag**: Der Tag vor einem gesetzlichen Feiertag
### Bonusberechnung
1. **Schwellenwert**: Mindestens **2.0 qualifizierende Tage** im Monat erforderlich
2. **Abzug**: Bei Erreichen des Schwellenwerts werden **2.0 qualifizierende Tage** abgezogen (Freitag-Priorität)
3. **Vergütung**:
- Normale Tage: **250€** pro Tag
- Qualifizierende Tage (WE/Feiertag): **450€** pro Tag
- Halbe Dienste: Jeweils die Hälfte
### Beispiel
Mitarbeiter hat im Monat:
- 3 normale Tage (Mo-Do, keine Feiertage)
- 3 Wochenend-Tage (Fr, Sa, So)
**Berechnung**:
- Qualifizierende Tage: 3.0 (Schwellenwert erreicht ✓)
- Abzug: -2.0 qualifizierende Tage
- Bezahlt: 3 normale Tage + 1 qualifizierender Tag
- **Bonus**: (3 × 250€) + (1 × 450€) = **1.200€**
## Installation & Nutzung
### Lokale Nutzung (einfachste Methode)
1. **Dateien öffnen**:
- Navigieren Sie zum Ordner `webapp`
- Öffnen Sie die Datei `index.html` direkt in Ihrem Browser (Doppelklick)
2. **Fertig!** Die App läuft komplett im Browser, keine Installation nötig.
### Mit lokalem Webserver (optional)
Wenn Sie lieber einen Webserver verwenden möchten:
```bash
# Im webapp-Ordner
python -m http.server 8000
# Oder mit Node.js
npx http-server -p 8000
```
Dann im Browser öffnen: `http://localhost:8000`
## Bedienung
### 1. Mitarbeiter hinzufügen
1. Gehen Sie zum Tab "Mitarbeiter verwalten"
2. Geben Sie den Namen ein und klicken Sie auf "Hinzufügen"
### 2. Dienste eintragen
1. Gehen Sie zum Tab "Dienste eintragen"
2. Wählen Sie Monat und Jahr
3. Wählen Sie einen Mitarbeiter
4. Wählen Sie das Datum
5. Wählen Sie Dienstanteil (ganz oder halb)
6. Klicken Sie auf "Dienst hinzufügen"
**Hinweis**: Qualifizierende Tage (WE/Feiertag) werden grün hervorgehoben.
### 3. Bonus berechnen
1. Gehen Sie zum Tab "Berechnung"
2. Wählen Sie Monat und Jahr
3. Klicken Sie auf "Berechnung durchführen"
4. Sehen Sie die Ergebnisse für alle Mitarbeiter
### 4. Daten exportieren/importieren
1. Gehen Sie zum Tab "Einstellungen"
2. Klicken Sie auf "Daten exportieren" für ein Backup
3. Verwenden Sie "Daten importieren" um gespeicherte Daten zu laden
## Datenspeicherung
- Alle Daten werden im **Browser LocalStorage** gespeichert
- Die Daten bleiben erhalten, auch nach Schließen des Browsers
- **Wichtig**: Beim Löschen der Browser-Daten gehen die Daten verloren
- Regelmäßige Exports werden empfohlen!
## NRW Feiertage (2025-2030)
Die App enthält alle gesetzlichen Feiertage für NRW von 2025 bis 2030:
- Neujahr
- Karfreitag
- Ostermontag
- Tag der Arbeit
- Christi Himmelfahrt
- Pfingstmontag
- Fronleichnam
- Tag der Deutschen Einheit
- Allerheiligen
- 1. und 2. Weihnachtstag
## Technische Details
### Projektstruktur
```
webapp/
├── index.html # Haupt-HTML-Datei
├── styles.css # Styling
├── app.js # Haupt-App-Logik & UI
├── calculator.js # Bonusberechnungs-Logik
├── holidays.js # NRW-Feiertagsdaten
├── storage.js # LocalStorage-Verwaltung
└── README.md # Diese Datei
```
### Technologien
- **Vanilla JavaScript** (kein Framework erforderlich)
- **HTML5 & CSS3**
- **LocalStorage API**
- Keine externen Abhängigkeiten
- Funktioniert in allen modernen Browsern
### Browser-Kompatibilität
- Chrome/Edge (empfohlen)
- Firefox
- Safari
- Opera
## Tipps & Tricks
1. **Regelmäßige Backups**: Exportieren Sie Ihre Daten regelmäßig als JSON-Datei
2. **Drucken**: Die Berechnungsseite kann direkt gedruckt werden (Datei → Drucken)
3. **Mehrere Browser**: Daten sind browser-spezifisch und werden nicht synchronisiert
4. **Mobile Nutzung**: Die App ist mobilfreundlich und kann auch auf Tablets/Smartphones genutzt werden
## Unterschiede zu anderen Versionen
Diese Web-App verwendet leicht andere Regeln als die Python/Excel Version:
### Web-App Logik (Ihre Anforderungen)
- Wenn < 2 WE-Tage: **Keine Bonuszahlung**
- Wenn ≥ 2 WE-Tage:
- 1 WE-Tag wird abgezogen
- Alle übrigen Tage werden bezahlt (normale: 250€, WE: 450€)
### Python/Excel Version (Variante 2 "streng")
- Normale Tage (WT) werden immer bezahlt (250€)
- WE-Tage nur wenn ≥ 2.0 WE-Einheiten
Die Web-App folgt genau Ihren beschriebenen Anforderungen.
## Lizenz
MIT

201
TEST_GUIDE.md Normal file
View file

@ -0,0 +1,201 @@
# Test Suite - Dienstplan Bonusrechner
Automatische Test Suite für die Web-App.
## Schnellstart
1. **Server starten** (falls noch nicht gestartet):
```bash
cd webapp
python3 -m http.server 8000
```
2. **Test-Seite öffnen**:
```
http://localhost:8000/test.html
```
3. **Tests ausführen**:
- Klicken Sie auf "Alle Tests ausführen"
- Warten Sie auf die Ergebnisse
- ✅ = Test bestanden
- ❌ = Test fehlgeschlagen
## Was wird getestet?
### 1. Holiday Provider (NRW-Feiertage)
- ✅ Feiertage werden korrekt erkannt
- ✅ Normale Tage werden nicht als Feiertage erkannt
- ✅ Tag vor Feiertag wird erkannt
- ✅ Spezifische Feiertage (Fronleichnam, etc.)
### 2. Calculator - Tag-Klassifizierung
- ✅ Freitag ist qualifizierend
- ✅ Samstag ist qualifizierend
- ✅ Sonntag ist qualifizierend
- ✅ Normale Wochentage (Mo-Do) sind nicht qualifizierend
- ✅ Feiertage sind qualifizierend
- ✅ Tag vor Feiertag ist qualifizierend
### 3. Calculator - Bonusberechnung
**Schwellenwert-Tests:**
- ✅ Unter Schwellenwert (1.0 WE-Tag) → 0€
- ✅ Genau Schwellenwert (2.0 WE-Tage) → 0€
- ✅ Über Schwellenwert (3.0 WE-Tage) → 450€
**Gemischte Dienste:**
- ✅ Normale Tage + WE-Tage korrekt berechnet
- ✅ Halbe Dienste korrekt berechnet
- ✅ Feiertag + Vortag-Kombination
**Spezialfälle:**
- ✅ Keine Dienste → 0€
- ✅ 2x halbe Samstage zählen als 1 ganzer Tag
### 4. Storage (Datenverwaltung)
- ✅ Mitarbeiter hinzufügen
- ✅ Doppelte Mitarbeiter werden abgelehnt
- ✅ Mitarbeiter entfernen
- ✅ Dienste hinzufügen und abrufen
- ✅ Dienste aktualisieren (gleicher Tag)
- ✅ Mehrere Mitarbeiter verwalten
- ✅ Export und Import von Daten
### 5. Edge Cases
- ✅ Rundungsfehler bei Schwellenwert
- ✅ Performance bei vielen Diensten (30+ Tage)
- ✅ Schaltjahre (29. Februar)
## Test-Statistiken
Nach dem Durchlauf sehen Sie:
- **Gesamt**: Anzahl aller Tests
- **Bestanden**: Anzahl erfolgreicher Tests
- **Fehlgeschlagen**: Anzahl fehlgeschlagener Tests
## Testfälle im Detail
### Beispiel 1: Schwellenwert genau erreicht
```javascript
Dienste:
- 1× Samstag (1.0)
- 1× Sonntag (1.0)
Erwartung:
- Qualifizierende Tage: 2.0
- Schwellenwert: ✅ Erreicht
- Abzug: -2.0
- Bezahlt: 0.0 × 450€ = 0€
```
### Beispiel 2: Gemischte Dienste
```javascript
Dienste:
- 2× Montag (2.0 normale Tage)
- 2× Samstag (2.0 qualifizierende Tage)
Erwartung:
- Normale Tage: 2.0 × 250€ = 500€
- Qualifizierende Tage: (2.0 - 2.0) × 450€ = 0€
- Gesamt: 500€
```
### Beispiel 3: Halbe Dienste
```javascript
Dienste:
- 1× Montag halber Dienst (0.5)
- 1× Samstag halber Dienst (0.5)
- 1× Sonntag ganzer Dienst (1.0)
- 1× Freitag ganzer Dienst (1.0)
Erwartung:
- Normale Tage: 0.5 × 250€ = 125€
- Qualifizierende Tage: (2.5 - 2.0) × 450€ = 225€
- Gesamt: 350€
```
## Tests erweitern
Um einen neuen Test hinzuzufügen, bearbeiten Sie `test-suite.js`:
```javascript
runner.test('Testname', (t) => {
// Setup
const calculator = new BonusCalculator(new HolidayProvider());
const duties = [
{ date: new Date('2025-11-22T12:00:00'), share: 1.0 }
];
// Ausführung
const result = calculator.calculateMonthlyBonus(duties);
// Assertions
t.assertEqual(result.totalBonus, 0, 'Erwarteter Bonus');
t.assertTrue(result.thresholdReached, 'Schwelle erreicht');
});
```
### Verfügbare Assertions
- `assertEqual(actual, expected, message)` - Exakte Gleichheit
- `assertAlmostEqual(actual, expected, tolerance, message)` - Ungefähre Gleichheit (für Fließkommazahlen)
- `assertTrue(value, message)` - Wert sollte true sein
- `assertFalse(value, message)` - Wert sollte false sein
## Troubleshooting
### Tests schlagen fehl
1. Prüfen Sie die Fehlermeldung (wird rot angezeigt)
2. Überprüfen Sie die erwarteten vs. erhaltenen Werte
3. Testen Sie die Funktion manuell in der Haupt-App
### Performance-Probleme
- Die Test Suite sollte in < 1 Sekunde durchlaufen
- Bei Verzögerungen: Browser-Konsole prüfen (F12)
### LocalStorage-Konflikte
- Tests verwenden die gleiche LocalStorage-Instanz wie die Haupt-App
- Bei Problemen: LocalStorage im Browser löschen
- Oder: Tests in Inkognito-Modus ausführen
## Continuous Integration
Die Tests können auch automatisiert mit Headless-Browsern ausgeführt werden:
```bash
# Mit Playwright
npx playwright test
# Mit Puppeteer
node run-tests-headless.js
```
(Erfordert zusätzliche Setup-Schritte)
## Test-Abdeckung
Aktuelle Abdeckung:
- **Feiertage**: 100% (alle NRW-Feiertage getestet)
- **Tag-Klassifizierung**: 100% (alle Wochentage + Feiertage)
- **Bonusberechnung**: ~95% (Hauptszenarien + Edge Cases)
- **Storage**: ~90% (CRUD-Operationen)
- **UI**: 0% (keine UI-Tests, nur Logik)
## Bekannte Limitierungen
1. **Keine UI-Tests**: Nur Logik-Tests, keine Interaktions-Tests
2. **Browser-abhängig**: LocalStorage-Tests funktionieren nur im Browser
3. **Keine Netzwerk-Tests**: Kein Server-seitiger Code
4. **Zeitzone**: Tests gehen von deutscher Zeitzone aus
## Best Practices
1. **Tests vor Änderungen ausführen**: Sicherstellen, dass alles funktioniert
2. **Nach Änderungen erneut testen**: Regression verhindern
3. **Neue Features = Neue Tests**: Test-first development
4. **Tests dokumentieren**: Klare Namen und Kommentare
## Lizenz
MIT (wie Hauptprojekt)

976
app.js Normal file
View file

@ -0,0 +1,976 @@
/**
* Main Application
* Manages UI interactions and coordinates between components
*/
class DienstplanApp {
constructor() {
this.storage = new DataStorage();
this.holidayProvider = new HolidayProvider();
this.calculator = new BonusCalculator(this.holidayProvider);
this.currentMonth = new Date().getMonth() + 1;
this.currentYear = new Date().getFullYear();
this.init();
}
init() {
this.setupEventListeners();
this.populateYearSelects();
this.setCurrentMonthYear();
this.loadEmployeeSelects();
this.loadEmployeeList();
this.switchTab('duties');
}
/**
* Setup all event listeners
*/
setupEventListeners() {
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
this.switchTab(e.target.dataset.tab);
});
});
// Employee management
document.getElementById('add-employee-btn').addEventListener('click', () => this.addEmployee());
document.getElementById('new-employee-name').addEventListener('keypress', (e) => {
if (e.key === 'Enter') this.addEmployee();
});
// 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());
// Calculation
document.getElementById('calculate-btn').addEventListener('click', () => this.calculateBonuses());
// Settings
document.getElementById('export-csv-btn').addEventListener('click', () => this.exportCSV());
document.getElementById('export-report-btn').addEventListener('click', () => this.exportBonusReport());
document.getElementById('export-btn').addEventListener('click', () => this.exportData());
document.getElementById('import-btn').addEventListener('click', () => this.importData());
document.getElementById('clear-all-btn').addEventListener('click', () => this.clearAllData());
}
/**
* Populate year select dropdowns
*/
populateYearSelects() {
const currentYear = new Date().getFullYear();
const years = [];
for (let year = currentYear - 1; year <= currentYear + 5; year++) {
years.push(year);
}
const yearSelects = ['year-select', 'calc-year-select'];
yearSelects.forEach(selectId => {
const select = document.getElementById(selectId);
select.innerHTML = '';
years.forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year;
if (year === currentYear) option.selected = true;
select.appendChild(option);
});
});
}
/**
* Set current month and year in selects
*/
setCurrentMonthYear() {
const currentMonth = new Date().getMonth() + 1;
const currentYear = new Date().getFullYear();
document.getElementById('month-select').value = currentMonth;
document.getElementById('year-select').value = currentYear;
document.getElementById('calc-month-select').value = currentMonth;
document.getElementById('calc-year-select').value = currentYear;
// Set date input to today
const today = new Date().toISOString().split('T')[0];
document.getElementById('duty-date').value = today;
}
/**
* Switch between tabs
*/
switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
if (btn.dataset.tab === tabName) {
btn.classList.add('active');
}
});
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`tab-${tabName}`).classList.add('active');
// Refresh data when switching to certain tabs
if (tabName === 'employees') {
this.loadEmployeeList();
} else if (tabName === 'duties') {
this.loadDutiesForSelectedEmployee();
}
}
/**
* Load employee select dropdowns
*/
loadEmployeeSelects() {
const employees = this.storage.getEmployees();
const selects = ['employee-select-duty'];
selects.forEach(selectId => {
const select = document.getElementById(selectId);
const currentValue = select.value;
select.innerHTML = '<option value="">-- Mitarbeiter auswählen --</option>';
employees.forEach(employee => {
const option = document.createElement('option');
option.value = employee;
option.textContent = employee;
select.appendChild(option);
});
// Restore previous selection if still valid
if (employees.includes(currentValue)) {
select.value = currentValue;
}
});
}
/**
* Add a new employee
*/
addEmployee() {
const input = document.getElementById('new-employee-name');
const name = input.value.trim();
if (!name) {
this.showToast('Bitte geben Sie einen Namen ein.', 'error');
return;
}
const success = this.storage.addEmployee(name);
if (success) {
this.showToast(`Mitarbeiter "${name}" wurde hinzugefügt.`, 'success');
input.value = '';
this.loadEmployeeList();
this.loadEmployeeSelects();
} else {
this.showToast(`Mitarbeiter "${name}" existiert bereits.`, 'error');
}
}
/**
* Remove an employee
*/
removeEmployee(employeeName) {
if (!confirm(`Möchten Sie "${employeeName}" wirklich löschen? Alle Dienste werden ebenfalls gelöscht.`)) {
return;
}
this.storage.removeEmployee(employeeName);
this.showToast(`Mitarbeiter "${employeeName}" wurde gelöscht.`, 'success');
this.loadEmployeeList();
this.loadEmployeeSelects();
this.loadDutiesForSelectedEmployee();
}
/**
* Load and display employee list
*/
loadEmployeeList() {
const employees = this.storage.getEmployees();
const container = document.getElementById('employee-list-display');
if (employees.length === 0) {
container.innerHTML = '<p class="text-muted">Keine Mitarbeiter vorhanden.</p>';
return;
}
container.innerHTML = '';
employees.forEach(employee => {
const item = document.createElement('div');
item.className = 'employee-item';
item.innerHTML = `
<span class="employee-name">${employee}</span>
<button class="btn btn-danger btn-small" onclick="app.removeEmployee('${employee}')">Löschen</button>
`;
container.appendChild(item);
});
}
/**
* Add a duty
*/
addDuty() {
const employeeSelect = document.getElementById('employee-select-duty');
const dateInput = document.getElementById('duty-date');
const shareSelect = document.getElementById('duty-share');
const employeeName = employeeSelect.value;
const dateStr = dateInput.value;
const share = parseFloat(shareSelect.value);
if (!employeeName) {
this.showToast('Bitte wählen Sie einen Mitarbeiter aus.', 'error');
return;
}
if (!dateStr) {
this.showToast('Bitte wählen Sie ein Datum aus.', 'error');
return;
}
const date = new Date(dateStr + 'T12:00:00'); // Add time to avoid timezone issues
const year = date.getFullYear();
const month = date.getMonth() + 1;
this.storage.addDuty(employeeName, year, month, date, share);
this.showToast('Dienst wurde hinzugefügt.', 'success');
this.loadDutiesForSelectedEmployee();
// Update month/year selects to match the added duty
document.getElementById('month-select').value = month;
document.getElementById('year-select').value = year;
}
/**
* Remove a duty
*/
removeDuty(employeeName, year, month, date) {
this.storage.removeDuty(employeeName, year, month, date);
this.showToast('Dienst wurde gelöscht.', 'success');
this.loadDutiesForSelectedEmployee();
}
/**
* Load duties for the selected employee and month
*/
loadDutiesForSelectedEmployee() {
const employeeSelect = document.getElementById('employee-select-duty');
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const container = document.getElementById('duties-display');
const employeeName = employeeSelect.value;
const month = parseInt(monthSelect.value);
const year = parseInt(yearSelect.value);
if (!employeeName) {
container.innerHTML = '<p class="text-muted">Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.</p>';
return;
}
const duties = this.storage.getDutiesForMonth(employeeName, year, month);
if (duties.length === 0) {
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
container.innerHTML = `<p class="text-muted">Keine Dienste für ${monthNames[month - 1]} ${year}.</p>`;
return;
}
container.innerHTML = '';
duties.forEach(duty => {
const isQualifying = this.calculator.isQualifyingDay(duty.date);
const dayType = this.calculator.getDayTypeLabel(duty.date);
const dateStr = duty.date.toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
const item = document.createElement('div');
item.className = `duty-item ${isQualifying ? 'qualifying' : ''}`;
item.innerHTML = `
<div class="duty-info">
<div class="duty-date">${dateStr}</div>
<div class="duty-meta">
${dayType}
<span class="badge ${isQualifying ? 'badge-qualifying' : 'badge-normal'}">
${isQualifying ? 'WE/Feiertag' : 'Normal'}
</span>
</div>
</div>
<div class="duty-share">${duty.share === 1 ? 'Ganzer Dienst' : 'Halber Dienst'}</div>
<button class="btn btn-danger btn-small"
onclick="app.removeDuty('${employeeName}', ${year}, ${month}, new Date('${duty.date.toISOString()}'))">
Löschen
</button>
`;
container.appendChild(item);
});
}
/**
* Calculate bonuses for all employees
*/
calculateBonuses() {
const monthSelect = document.getElementById('calc-month-select');
const yearSelect = document.getElementById('calc-year-select');
const resultsContainer = document.getElementById('calculation-results');
const month = parseInt(monthSelect.value);
const year = parseInt(yearSelect.value);
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
const results = this.calculator.calculateAllEmployees(employeeDuties);
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
resultsContainer.innerHTML = `<h3>Ergebnisse für ${monthNames[month - 1]} ${year}</h3>`;
const employees = Object.keys(results);
if (employees.length === 0) {
resultsContainer.innerHTML += '<p class="text-muted">Keine Daten verfügbar.</p>';
return;
}
employees.forEach(employeeName => {
const result = results[employeeName];
const resultCard = this.createResultCard(employeeName, result);
resultsContainer.appendChild(resultCard);
});
this.showToast('Berechnung abgeschlossen.', 'success');
}
/**
* Create a result card for an employee
*/
createResultCard(employeeName, result) {
const card = document.createElement('div');
card.className = 'result-card';
let content = `<h3>${employeeName}</h3>`;
if (!result.thresholdReached) {
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>
<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>
<div class="amount">${this.calculator.formatCurrency(result.totalBonus)}</div>
</div>
`;
}
card.innerHTML = content;
return card;
}
/**
* Export data as JSON
*/
exportData() {
const data = this.storage.exportData();
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `dienstplan-export-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast('Daten wurden exportiert.', 'success');
}
/**
* Export data as CSV (Excel-compatible) - Beginner-friendly format
* Exports all duties and monthly summary for the selected month
*/
exportCSV() {
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const month = parseInt(document.getElementById('calc-month-select').value);
const year = parseInt(document.getElementById('calc-year-select').value);
// Helper function to escape CSV values (handles semicolons, quotes, newlines)
const escapeCSV = (value) => {
const str = String(value);
if (str.includes(';') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
// Build CSV content with BOM for Excel UTF-8 support
let csv = '\uFEFF'; // UTF-8 BOM for Excel
// === Sheet 1: Dienste (All Duties for the month) ===
csv += `DIENSTE ${monthNames[month - 1]} ${year}\n`;
csv += 'Datum;Wochentag;Mitarbeiter;Anteil;Tagestyp\n';
const employees = this.storage.getEmployees();
const allDuties = [];
// Collect all duties for the selected month from all employees
employees.forEach(employee => {
const duties = this.storage.getDutiesForMonth(employee, year, month);
duties.forEach(duty => {
allDuties.push({
...duty,
employee: employee
});
});
});
// Sort by date
allDuties.sort((a, b) => a.date - b.date);
allDuties.forEach(duty => {
const isQual = this.calculator.isQualifyingDay(duty.date);
const dateStr = duty.date.toLocaleDateString('de-DE');
const weekday = weekdays[duty.date.getDay()];
const dayType = isQual ? 'WE-Tag' : 'Werktag (WT)';
csv += `${dateStr};${weekday};${escapeCSV(duty.employee)};${duty.share.toFixed(1).replace('.', ',')};${dayType}\n`;
});
csv += '\n\n';
// === 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';
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
const results = this.calculator.calculateAllEmployees(employeeDuties);
let totalBonus = 0;
for (const [employeeName, result] of Object.entries(results)) {
const threshold = result.thresholdReached ? 'JA' : 'NEIN';
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.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';
// Download CSV file
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `Dienstplan_${year}_${String(month).padStart(2, '0')}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showToast('CSV wurde exportiert. Öffnen Sie die Datei mit Excel oder LibreOffice.', 'success');
}
/**
* Export a formal bonus report in HTML format
* Opens in a new window for printing or saving as PDF
*/
exportBonusReport() {
const monthNames = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
const weekdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
const month = parseInt(document.getElementById('calc-month-select').value);
const year = parseInt(document.getElementById('calc-year-select').value);
// Calculate next month for payout date
const payoutMonth = month % 12;
const payoutYear = month === 12 ? year + 1 : year;
const employeeDuties = this.storage.getAllEmployeeDutiesForMonth(year, month);
const employees = Object.keys(employeeDuties);
if (employees.length === 0) {
this.showToast('Keine Dienste für diesen Monat vorhanden.', 'error');
return;
}
// Escape HTML function
const escapeHtml = (str) => {
return String(str).replace(/[&<>"']/g, c => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;'
}[c]));
};
// Group duties by employee and weekday
const employeeData = {};
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
};
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;
}
});
}
// Build HTML report
let html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bonuszahlungen ${monthNames[month - 1]} ${year}</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 40px;
color: #333;
line-height: 1.6;
}
h3 {
color: #4472C4;
border-bottom: 2px solid #4472C4;
padding-bottom: 10px;
}
h5 {
color: #666;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
th, td {
border: 1px solid #ddd;
padding: 10px 8px;
text-align: center;
}
th {
background-color: #4472C4;
color: white;
font-weight: bold;
}
tr:nth-child(even) {
background-color: #f9f9f9;
}
.employee-name {
text-align: left;
font-weight: bold;
}
.bonus-amount {
font-weight: bold;
color: #28a745;
}
.no-bonus {
color: #dc3545;
}
.duty-cell {
font-size: 0.85em;
}
.duty-cell .we-tag {
background: #d4edda;
color: #155724;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.duty-cell .wt-tag {
background: #e7e7e7;
color: #666;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
}
.duty-cell .deducted-tag {
background: #fff3cd;
color: #856404;
padding: 2px 6px;
border-radius: 3px;
font-size: 0.9em;
border: 1px dashed #856404;
}
.employee-note {
margin: 10px 0;
padding: 10px;
background: #f8f9fa;
border-left: 3px solid #4472C4;
}
.employee-note b {
color: #4472C4;
}
.summary {
margin-top: 30px;
padding: 20px;
background: #e7f3ff;
border-radius: 8px;
}
.total {
font-size: 1.2em;
font-weight: bold;
color: #4472C4;
}
@media print {
body { margin: 20px; }
.no-print { display: none; }
}
</style>
</head>
<body>
<div class="no-print" style="margin-bottom: 20px; padding: 10px; background: #fff3cd; border-radius: 5px;">
<button onclick="window.print()" style="padding: 8px 16px; background: #4472C4; color: white; border: none; border-radius: 4px; cursor: pointer; margin-right: 10px;">🖨 Drucken / Als PDF speichern</button>
<span style="color: #666;">Tipp: Beim Drucken "Als PDF speichern" wählen für eine PDF-Datei.</span>
</div>
<h3>Bonuszahlungen</h3>
<h5>Monat ${monthNames[month - 1]} ${year} mit Auszahlung Ende ${monthNames[payoutMonth]} ${payoutYear}</h5>
<p>Für die im ${monthNames[month - 1]} ${year} geleisteten Bereitschaftsdienste ergeben sich folgende Bonuszahlungen:</p>
<table>
<thead>
<tr>
<th>Mitarbeiter</th>
<th>Mo</th>
<th>Di</th>
<th>Mi</th>
<th>Do</th>
<th>Fr</th>
<th>Sa</th>
<th>So</th>
<th>Bonus ()</th>
</tr>
</thead>
<tbody>`;
let totalBonus = 0;
const employeeNotes = [];
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;
let deductedFrom = '';
let deduct_fr = 0;
let deduct_other = 0;
if (thresholdReached) {
const wt_pay = data.wt * this.calculator.RATE_NORMAL;
let deduct = this.calculator.DEDUCTION_AMOUNT;
deduct_fr = Math.min(deduct, data.we_fr);
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;
if (deduct_fr > 0 && deduct_other > 0) {
deductedFrom = 'Freitag und weiterer WE-Tag';
} else if (deduct_fr > 0) {
deductedFrom = 'Freitag';
} else {
deductedFrom = 'WE-Tag (Sa/So/Feiertag)';
}
}
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.`;
} 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 += '.';
}
employeeNotes.push(note);
// Track remaining deduction for each duty (Friday first, then others)
let remainingDeductFr = deduct_fr;
let remainingDeductOther = deduct_other;
// 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) {
html += `<td></td>`;
} else {
let cellContent = '';
dayDuties.forEach(duty => {
const dateStr = duty.date.getDate() + '.';
const shareStr = duty.share === 0.5 ? '½' : '';
const isFriday = duty.date.getDay() === 5;
const isHoliday = this.holidayProvider.isHoliday(duty.date);
const isDayBefore = this.holidayProvider.isDayBeforeHoliday(duty.date);
const extraInfo = isHoliday ? ' (Feiertag)' : isDayBefore ? ' (Vor Feiertag)' : '';
// Determine if this duty is deducted
let deductedAmount = 0;
let paidAmount = duty.share;
if (thresholdReached && duty.isQual) {
if (isFriday && remainingDeductFr > 0) {
deductedAmount = Math.min(duty.share, remainingDeductFr);
remainingDeductFr -= deductedAmount;
} else if (!isFriday && remainingDeductOther > 0) {
deductedAmount = Math.min(duty.share, remainingDeductOther);
remainingDeductOther -= deductedAmount;
}
paidAmount = duty.share - deductedAmount;
}
const isFullyDeducted = thresholdReached && duty.isQual && deductedAmount >= duty.share - 0.0001;
// Calculate euro amount only for paid portion
const rate = duty.isQual ? this.calculator.RATE_WEEKEND : this.calculator.RATE_NORMAL;
const amountStr = `${Math.round(paidAmount * rate)}`;
// Determine tag style
let tag = duty.isQual ? 'we-tag' : 'wt-tag';
if (isFullyDeducted) {
tag = 'deducted-tag';
}
// Build cell content
cellContent += `<span class="${tag}">${shareStr}X${extraInfo}</span><br>`;
// Only show euro amount for non-deducted or partially-paid days
if (!isFullyDeducted && (paidAmount > 0 || !duty.isQual)) {
cellContent += `<small>${amountStr}</small><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>`;
}
html += `
</tbody>
</table>
<div class="summary">
<p class="total">Gesamtsumme: ${this.calculator.formatCurrency(totalBonus)}</p>
</div>
<h4>Erläuterungen zu den einzelnen Mitarbeitern:</h4>
`;
employeeNotes.forEach(note => {
html += `<div class="employee-note">${note}</div>\n`;
});
html += `
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #ddd;">
<p><strong>Berechnungsregeln (Variante 2 - Streng):</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>
</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)
</p>
</body>
</html>`;
// Open in new window
const reportWindow = window.open('', '_blank');
if (reportWindow) {
reportWindow.document.write(html);
reportWindow.document.close();
this.showToast('Bonus-Bericht wurde in einem neuen Fenster geöffnet.', 'success');
} else {
this.showToast('Popup wurde blockiert. Bitte erlauben Sie Popups für diese Seite.', 'error');
}
}
/**
* Import data from JSON file
*/
importData() {
const fileInput = document.getElementById('import-file');
const file = fileInput.files[0];
if (!file) {
this.showToast('Bitte wählen Sie eine Datei aus.', 'error');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const success = this.storage.importData(e.target.result);
if (success) {
this.showToast('Daten wurden erfolgreich importiert.', 'success');
this.loadEmployeeList();
this.loadEmployeeSelects();
this.loadDutiesForSelectedEmployee();
} else {
this.showToast('Import fehlgeschlagen. Bitte überprüfen Sie die Datei.', 'error');
}
};
reader.readAsText(file);
fileInput.value = ''; // Reset file input
}
/**
* Clear all data
*/
clearAllData() {
if (!confirm('Möchten Sie wirklich ALLE Daten löschen? Diese Aktion kann nicht rückgängig gemacht werden!')) {
return;
}
this.storage.clearAll();
this.showToast('Alle Daten wurden gelöscht.', 'info');
this.loadEmployeeList();
this.loadEmployeeSelects();
this.loadDutiesForSelectedEmployee();
}
/**
* Show toast notification
*/
showToast(message, type = 'info') {
const toast = document.getElementById('toast');
toast.textContent = message;
toast.className = `toast ${type}`;
setTimeout(() => {
toast.classList.add('show');
}, 100);
setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}
}
// Initialize app when DOM is ready
let app;
document.addEventListener('DOMContentLoaded', () => {
app = new DienstplanApp();
});

207
calculator.js Normal file
View file

@ -0,0 +1,207 @@
/**
* Duty Schedule Bonus Calculator
* Calculates bonuses based on weekend and holiday duty shifts
*/
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
}
/**
* 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}
*/
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;
}
/**
* Get day type label for display
* @param {Date} date
* @returns {string}
*/
getDayTypeLabel(date) {
const dayOfWeek = date.getDay();
const isHoliday = this.holidayProvider.isHoliday(date);
const holidayName = this.holidayProvider.getHolidayName(date);
const isDayBefore = this.holidayProvider.isDayBeforeHoliday(date);
if (isHoliday) {
return `Feiertag (${holidayName})`;
}
if (isDayBefore) {
return 'Tag vor Feiertag';
}
if (dayOfWeek === 5) return 'Freitag';
if (dayOfWeek === 6) return 'Samstag';
if (dayOfWeek === 0) return 'Sonntag';
const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
return days[dayOfWeek];
}
/**
* 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
*/
calculateMonthlyBonus(duties) {
if (!duties || duties.length === 0) {
return this.getEmptyResult();
}
// Separate qualifying days (Friday separate from others) and non-qualifying days
let qualifyingDaysFriday = 0;
let qualifyingDaysOther = 0;
let normalDays = 0;
const dutyDetails = [];
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;
}
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)
return {
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
};
}
/**
* 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
*/
calculateAllEmployees(employeeDuties) {
const results = {};
for (const [employeeName, duties] of Object.entries(employeeDuties)) {
results[employeeName] = this.calculateMonthlyBonus(duties);
}
return results;
}
/**
* Get empty result structure
* @returns {Object}
*/
getEmptyResult() {
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,
totalBonus: 0,
dutyDetails: []
};
}
/**
* Format currency for display
* @param {number} amount
* @returns {string}
*/
formatCurrency(amount) {
return new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR'
}).format(amount);
}
}
// Make it available globally
window.BonusCalculator = BonusCalculator;

156
holidays.js Normal file
View file

@ -0,0 +1,156 @@
/**
* NRW Public Holidays Provider
* Provides holidays for NRW (Nordrhein-Westfalen) from 2025-2030
*/
class HolidayProvider {
constructor() {
this.holidays = this.initializeHolidays();
}
initializeHolidays() {
return {
2025: [
{ date: '2025-01-01', name: 'Neujahr' },
{ date: '2025-04-18', name: 'Karfreitag' },
{ date: '2025-04-21', name: 'Ostermontag' },
{ date: '2025-05-01', name: 'Tag der Arbeit' },
{ date: '2025-05-29', name: 'Christi Himmelfahrt' },
{ date: '2025-06-09', name: 'Pfingstmontag' },
{ date: '2025-06-19', name: 'Fronleichnam' },
{ date: '2025-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2025-11-01', name: 'Allerheiligen' },
{ date: '2025-12-25', name: '1. Weihnachtstag' },
{ date: '2025-12-26', name: '2. Weihnachtstag' }
],
2026: [
{ date: '2026-01-01', name: 'Neujahr' },
{ date: '2026-04-03', name: 'Karfreitag' },
{ date: '2026-04-06', name: 'Ostermontag' },
{ date: '2026-05-01', name: 'Tag der Arbeit' },
{ date: '2026-05-14', name: 'Christi Himmelfahrt' },
{ date: '2026-05-25', name: 'Pfingstmontag' },
{ date: '2026-06-04', name: 'Fronleichnam' },
{ date: '2026-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2026-11-01', name: 'Allerheiligen' },
{ date: '2026-12-25', name: '1. Weihnachtstag' },
{ date: '2026-12-26', name: '2. Weihnachtstag' }
],
2027: [
{ date: '2027-01-01', name: 'Neujahr' },
{ date: '2027-03-26', name: 'Karfreitag' },
{ date: '2027-03-29', name: 'Ostermontag' },
{ date: '2027-05-01', name: 'Tag der Arbeit' },
{ date: '2027-05-06', name: 'Christi Himmelfahrt' },
{ date: '2027-05-17', name: 'Pfingstmontag' },
{ date: '2027-05-27', name: 'Fronleichnam' },
{ date: '2027-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2027-11-01', name: 'Allerheiligen' },
{ date: '2027-12-25', name: '1. Weihnachtstag' },
{ date: '2027-12-26', name: '2. Weihnachtstag' }
],
2028: [
{ date: '2028-01-01', name: 'Neujahr' },
{ date: '2028-04-14', name: 'Karfreitag' },
{ date: '2028-04-17', name: 'Ostermontag' },
{ date: '2028-05-01', name: 'Tag der Arbeit' },
{ date: '2028-05-25', name: 'Christi Himmelfahrt' },
{ date: '2028-06-05', name: 'Pfingstmontag' },
{ date: '2028-06-15', name: 'Fronleichnam' },
{ date: '2028-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2028-11-01', name: 'Allerheiligen' },
{ date: '2028-12-25', name: '1. Weihnachtstag' },
{ date: '2028-12-26', name: '2. Weihnachtstag' }
],
2029: [
{ date: '2029-01-01', name: 'Neujahr' },
{ date: '2029-03-30', name: 'Karfreitag' },
{ date: '2029-04-02', name: 'Ostermontag' },
{ date: '2029-05-01', name: 'Tag der Arbeit' },
{ date: '2029-05-10', name: 'Christi Himmelfahrt' },
{ date: '2029-05-21', name: 'Pfingstmontag' },
{ date: '2029-05-31', name: 'Fronleichnam' },
{ date: '2029-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2029-11-01', name: 'Allerheiligen' },
{ date: '2029-12-25', name: '1. Weihnachtstag' },
{ date: '2029-12-26', name: '2. Weihnachtstag' }
],
2030: [
{ date: '2030-01-01', name: 'Neujahr' },
{ date: '2030-04-19', name: 'Karfreitag' },
{ date: '2030-04-22', name: 'Ostermontag' },
{ date: '2030-05-01', name: 'Tag der Arbeit' },
{ date: '2030-05-30', name: 'Christi Himmelfahrt' },
{ date: '2030-06-10', name: 'Pfingstmontag' },
{ date: '2030-06-20', name: 'Fronleichnam' },
{ date: '2030-10-03', name: 'Tag der Deutschen Einheit' },
{ date: '2030-11-01', name: 'Allerheiligen' },
{ date: '2030-12-25', name: '1. Weihnachtstag' },
{ date: '2030-12-26', name: '2. Weihnachtstag' }
]
};
}
/**
* Check if a given date is a public holiday
* @param {Date} date - Date to check
* @returns {boolean}
*/
isHoliday(date) {
const year = date.getFullYear();
const dateStr = this.formatDate(date);
if (!this.holidays[year]) return false;
return this.holidays[year].some(h => h.date === dateStr);
}
/**
* Check if a given date is the day before a public holiday
* @param {Date} date - Date to check
* @returns {boolean}
*/
isDayBeforeHoliday(date) {
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
return this.isHoliday(nextDay);
}
/**
* Get holiday name for a given date (if it is a holiday)
* @param {Date} date - Date to check
* @returns {string|null}
*/
getHolidayName(date) {
const year = date.getFullYear();
const dateStr = this.formatDate(date);
if (!this.holidays[year]) return null;
const holiday = this.holidays[year].find(h => h.date === dateStr);
return holiday ? holiday.name : null;
}
/**
* Format date as YYYY-MM-DD
* @param {Date} date
* @returns {string}
*/
formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Get all holidays for a specific year
* @param {number} year
* @returns {Array}
*/
getHolidaysForYear(year) {
return this.holidays[year] || [];
}
}
// Make it available globally
window.HolidayProvider = HolidayProvider;

217
index.html Normal file
View file

@ -0,0 +1,217 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dienstplan Pro</title>
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#0f172a">
<link rel="stylesheet" href="styles.css">
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(() => console.log('Service Worker Registered'));
}
</script>
</head>
<body>
<div class="container">
<header>
<h1>Dienstplan Pro</h1>
<p class="subtitle">Bonuszahlungen für Wochenend- und Feiertagsdienste (NRW)</p>
</header>
<!-- Navigation Tabs -->
<div class="tabs">
<button class="tab-btn active" data-tab="duties">Dienste eintragen</button>
<button class="tab-btn" data-tab="calculation">Berechnung</button>
<button class="tab-btn" data-tab="employees">Mitarbeiter verwalten</button>
<button class="tab-btn" data-tab="settings">Einstellungen</button>
</div>
<!-- Tab: Dienste eintragen -->
<div id="tab-duties" class="tab-content active">
<div class="card">
<h2>Dienste eintragen</h2>
<!-- Month Selection -->
<div class="form-group">
<label for="month-select">Monat auswählen:</label>
<div class="month-selector">
<select id="month-select">
<option value="1">Januar</option>
<option value="2">Februar</option>
<option value="3">März</option>
<option value="4">April</option>
<option value="5">Mai</option>
<option value="6">Juni</option>
<option value="7">Juli</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<select id="year-select">
<!-- Will be populated by JavaScript -->
</select>
</div>
</div>
<!-- Employee Selection -->
<div class="form-group">
<label for="employee-select-duty">Mitarbeiter:</label>
<select id="employee-select-duty">
<option value="">-- Mitarbeiter auswählen --</option>
</select>
</div>
<!-- Add Duty Form -->
<div class="form-group">
<label for="duty-date">Datum:</label>
<input type="date" id="duty-date">
</div>
<div class="form-group">
<label for="duty-share">Dienstanteil:</label>
<select id="duty-share">
<option value="1">Ganzer Dienst (1.0)</option>
<option value="0.5">Halber Dienst (0.5)</option>
</select>
</div>
<button id="add-duty-btn" class="btn btn-primary">Dienst hinzufügen</button>
<!-- Duties List -->
<div class="duties-list">
<h3>Eingetragene Dienste</h3>
<div id="duties-display">
<p class="text-muted">Wählen Sie einen Mitarbeiter aus, um Dienste anzuzeigen.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Berechnung -->
<div id="tab-calculation" class="tab-content">
<div class="card">
<h2>Bonusberechnung</h2>
<!-- Month Selection for Calculation -->
<div class="form-group">
<label for="calc-month-select">Monat auswählen:</label>
<div class="month-selector">
<select id="calc-month-select">
<option value="1">Januar</option>
<option value="2">Februar</option>
<option value="3">März</option>
<option value="4">April</option>
<option value="5">Mai</option>
<option value="6">Juni</option>
<option value="7">Juli</option>
<option value="8">August</option>
<option value="9">September</option>
<option value="10">Oktober</option>
<option value="11">November</option>
<option value="12">Dezember</option>
</select>
<select id="calc-year-select">
<!-- Will be populated by JavaScript -->
</select>
</div>
</div>
<button id="calculate-btn" class="btn btn-primary">Berechnung durchführen</button>
<!-- Results Display -->
<div id="calculation-results">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Tab: Mitarbeiter verwalten -->
<div id="tab-employees" class="tab-content">
<div class="card">
<h2>Mitarbeiter verwalten</h2>
<!-- Add Employee -->
<div class="form-group">
<label for="new-employee-name">Neuer Mitarbeiter:</label>
<div class="input-group">
<input type="text" id="new-employee-name" placeholder="Name eingeben...">
<button id="add-employee-btn" class="btn btn-primary">Hinzufügen</button>
</div>
</div>
<!-- Employee List -->
<div class="employee-list">
<h3>Mitarbeiter</h3>
<div id="employee-list-display">
<p class="text-muted">Keine Mitarbeiter vorhanden.</p>
</div>
</div>
</div>
</div>
<!-- Tab: Einstellungen -->
<div id="tab-settings" class="tab-content">
<div class="card">
<h2>Einstellungen & Daten</h2>
<div class="settings-section">
<h3>Berechnungsregeln</h3>
<div class="info-box">
<h4>Qualifizierende Tage (WE/Feiertag):</h4>
<ul>
<li>Freitag, Samstag, Sonntag</li>
<li>Feiertage in NRW</li>
<li>Tag vor einem Feiertag</li>
</ul>
<h4>Bonusberechnung:</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>
</ul>
<h4>Wichtig:</h4>
<p>Wenn weniger als 2.0 qualifizierende Tage erreicht werden, erfolgt <strong>keine Bonuszahlung</strong>.</p>
</div>
</div>
<div class="settings-section">
<h3>Datenexport / Import</h3>
<button id="export-csv-btn" class="btn btn-success">📊 Excel/CSV Export</button>
<button id="export-report-btn" class="btn btn-primary">📝 Bonus-Bericht</button>
<button id="export-btn" class="btn btn-secondary">Daten exportieren (JSON)</button>
<p class="text-muted" style="margin-top: 10px;">💡 <strong>Tipp:</strong> CSV-Dateien können direkt mit Excel, LibreOffice oder Google Sheets geöffnet werden. Der Bonus-Bericht öffnet sich in einem neuen Fenster zum Drucken.</p>
<div class="form-group">
<label for="import-file">Daten importieren:</label>
<input type="file" id="import-file" accept=".json">
<button id="import-btn" class="btn btn-secondary">Importieren</button>
</div>
</div>
<div class="settings-section">
<h3>Alle Daten löschen</h3>
<p class="text-warning">Achtung: Diese Aktion kann nicht rückgängig gemacht werden!</p>
<button id="clear-all-btn" class="btn btn-danger">Alle Daten löschen</button>
</div>
</div>
</div>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<!-- Scripts -->
<script src="holidays.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="app.js"></script>
</body>
</html>

21
manifest.json Normal file
View file

@ -0,0 +1,21 @@
{
"name": "Dienstplan Pro",
"short_name": "Dienstplan",
"start_url": "./index.html",
"display": "standalone",
"background_color": "#1e293b",
"theme_color": "#0f172a",
"description": "Automatische Dienstplan-Vergütung (NRW)",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

313
storage.js Normal file
View file

@ -0,0 +1,313 @@
/**
* Data Storage Manager
* Manages employee and duty data using localStorage
*/
class DataStorage {
constructor() {
this.STORAGE_KEY_EMPLOYEES = 'dienstplan_employees';
this.STORAGE_KEY_DUTIES = 'dienstplan_duties';
}
/**
* Get all employees
* @returns {Array} Array of employee names
*/
getEmployees() {
try {
const data = localStorage.getItem(this.STORAGE_KEY_EMPLOYEES);
if (!data) {
return [];
}
const parsed = JSON.parse(data);
if (!Array.isArray(parsed)) {
console.error('Fehler: Mitarbeiter-Daten sind kein Array. Zurücksetzen auf leeres Array');
return [];
}
return parsed;
} catch (e) {
console.error('Fehler beim Laden der Mitarbeiter-Daten:', e);
return [];
}
}
/**
* Save employees list
* @param {Array} employees - Array of employee names
*/
saveEmployees(employees) {
try {
if (!Array.isArray(employees)) {
console.error('Fehler: employees muss ein Array sein');
throw new TypeError('employees muss ein Array sein');
}
localStorage.setItem(this.STORAGE_KEY_EMPLOYEES, JSON.stringify(employees));
} catch (e) {
console.error('Fehler beim Speichern der Mitarbeiter-Daten:', e);
throw e;
}
}
/**
* Add a new employee
* @param {string} employeeName
* @returns {boolean} Success status
*/
addEmployee(employeeName) {
const employees = this.getEmployees();
if (employees.includes(employeeName)) {
return false; // Already exists
}
employees.push(employeeName);
this.saveEmployees(employees.sort());
return true;
}
/**
* Remove an employee and all their duties
* @param {string} employeeName
*/
removeEmployee(employeeName) {
// Remove from employees list
const employees = this.getEmployees();
const filtered = employees.filter(e => e !== employeeName);
this.saveEmployees(filtered);
// Remove all duties for this employee
const allDuties = this.getAllDuties();
delete allDuties[employeeName];
this.saveAllDuties(allDuties);
}
/**
* Get all duties data (all employees, all months)
* @returns {Object} Object with structure: {employeeName: {year-month: [duties]}}
*/
getAllDuties() {
try {
const data = localStorage.getItem(this.STORAGE_KEY_DUTIES);
if (!data) {
return {};
}
const parsed = JSON.parse(data);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
console.error('Fehler: Dienst-Daten sind kein gültiges Objekt. Zurücksetzen auf leeres Objekt');
return {};
}
return parsed;
} catch (e) {
console.error('Fehler beim Laden der Dienst-Daten:', e);
return {};
}
}
/**
* Save all duties data
* @param {Object} duties
*/
saveAllDuties(duties) {
try {
if (typeof duties !== 'object' || duties === null || Array.isArray(duties)) {
console.error('Fehler: duties muss ein gültiges Objekt sein');
throw new TypeError('duties muss ein gültiges Objekt sein');
}
localStorage.setItem(this.STORAGE_KEY_DUTIES, JSON.stringify(duties));
} catch (e) {
console.error('Fehler beim Speichern der Dienst-Daten:', e);
throw e;
}
}
/**
* Get duties for a specific employee and month
* @param {string} employeeName
* @param {number} year
* @param {number} month (1-12)
* @returns {Array} Array of duty objects
*/
getDutiesForMonth(employeeName, year, month) {
try {
const allDuties = this.getAllDuties();
const monthKey = `${year}-${String(month).padStart(2, '0')}`;
if (!allDuties[employeeName] || !allDuties[employeeName][monthKey]) {
return [];
}
// Convert date strings back to Date objects
return allDuties[employeeName][monthKey].map(duty => {
try {
const dateObj = new Date(duty.date);
if (isNaN(dateObj.getTime())) {
console.error(`Fehler: Ungültiges Datum für Dienst: ${duty.date}`);
return null;
}
return {
...duty,
date: dateObj
};
} catch (e) {
console.error('Fehler beim Konvertieren des Datums:', e);
return null;
}
}).filter(duty => duty !== null); // Filter out invalid entries
} catch (e) {
console.error('Fehler beim Laden der Dienste für Monat:', e);
return [];
}
}
/**
* Save duties for a specific employee and month
* @param {string} employeeName
* @param {number} year
* @param {number} month (1-12)
* @param {Array} duties - Array of duty objects
*/
saveDutiesForMonth(employeeName, year, month, duties) {
try {
if (!Array.isArray(duties)) {
console.error('Fehler: duties muss ein Array sein');
throw new TypeError('duties muss ein Array sein');
}
const allDuties = this.getAllDuties();
const monthKey = `${year}-${String(month).padStart(2, '0')}`;
if (!allDuties[employeeName]) {
allDuties[employeeName] = {};
}
// Convert Date objects to strings for storage
allDuties[employeeName][monthKey] = duties.map(duty => {
if (!duty.date || !(duty.date instanceof Date)) {
console.error('Fehler: Dienst hat kein gültiges Datum:', duty);
throw new TypeError('Dienst muss ein gültiges Date-Objekt haben');
}
return {
...duty,
date: duty.date.toISOString()
};
});
this.saveAllDuties(allDuties);
} catch (e) {
console.error('Fehler beim Speichern der Dienste für Monat:', e);
throw e;
}
}
/**
* Add a duty for an employee
* @param {string} employeeName
* @param {number} year
* @param {number} month (1-12)
* @param {Date} date
* @param {number} share (1.0 or 0.5)
*/
addDuty(employeeName, year, month, date, share) {
const duties = this.getDutiesForMonth(employeeName, year, month);
// Check if duty already exists for this date
const existingIndex = duties.findIndex(d =>
d.date.toDateString() === date.toDateString()
);
if (existingIndex >= 0) {
// Update existing duty
duties[existingIndex].share = share;
} else {
// Add new duty
duties.push({ date, share });
}
// Sort by date
duties.sort((a, b) => a.date - b.date);
this.saveDutiesForMonth(employeeName, year, month, duties);
}
/**
* Remove a duty
* @param {string} employeeName
* @param {number} year
* @param {number} month (1-12)
* @param {Date} date
*/
removeDuty(employeeName, year, month, date) {
const duties = this.getDutiesForMonth(employeeName, year, month);
const filtered = duties.filter(d =>
d.date.toDateString() !== date.toDateString()
);
this.saveDutiesForMonth(employeeName, year, month, filtered);
}
/**
* Get all duties for all employees in a specific month
* @param {number} year
* @param {number} month (1-12)
* @returns {Object} Object with employee names as keys
*/
getAllEmployeeDutiesForMonth(year, month) {
const employees = this.getEmployees();
const result = {};
employees.forEach(employee => {
result[employee] = this.getDutiesForMonth(employee, year, month);
});
return result;
}
/**
* Clear all data
*/
clearAll() {
localStorage.removeItem(this.STORAGE_KEY_EMPLOYEES);
localStorage.removeItem(this.STORAGE_KEY_DUTIES);
}
/**
* Export data as JSON
* @returns {string} JSON string
*/
exportData() {
try {
return JSON.stringify({
employees: this.getEmployees(),
duties: this.getAllDuties()
}, null, 2);
} catch (e) {
console.error('Fehler beim Exportieren der Daten:', e);
throw new Error('Fehler beim Exportieren der Daten: ' + e.message);
}
}
/**
* Import data from JSON
* @param {string} jsonString
* @returns {boolean} Success status
*/
importData(jsonString) {
try {
const data = JSON.parse(jsonString);
if (data.employees) {
this.saveEmployees(data.employees);
}
if (data.duties) {
this.saveAllDuties(data.duties);
}
return true;
} catch (e) {
console.error('Import failed:', e);
return false;
}
}
}
// Make it available globally
window.DataStorage = DataStorage;

538
styles.css Normal file
View file

@ -0,0 +1,538 @@
/* Reset and Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
/* Header */
header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
header h1 {
font-size: 2rem;
margin-bottom: 10px;
}
.subtitle {
font-size: 1rem;
opacity: 0.9;
}
/* Tabs */
.tabs {
display: flex;
background: #f8f9fa;
border-bottom: 2px solid #e0e0e0;
overflow-x: auto;
}
.tab-btn {
flex: 1;
padding: 15px 20px;
border: none;
background: transparent;
cursor: pointer;
font-size: 1rem;
font-weight: 500;
color: #666;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-btn:hover {
background: rgba(102, 126, 234, 0.1);
color: #667eea;
}
.tab-btn.active {
background: white;
color: #667eea;
border-bottom: 3px solid #667eea;
}
/* Tab Content */
.tab-content {
display: none;
padding: 30px;
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Card */
.card {
background: white;
}
.card h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.5rem;
}
.card h3 {
color: #333;
margin: 20px 0 10px;
font-size: 1.2rem;
}
/* Form Elements */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.form-group input[type="text"],
.form-group input[type="date"],
.form-group input[type="file"],
.form-group select {
width: 100%;
padding: 10px 15px;
border: 2px solid #e0e0e0;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.3s ease;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #667eea;
}
.month-selector {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 10px;
}
.input-group {
display: flex;
gap: 10px;
}
.input-group input {
flex: 1;
}
/* Buttons */
.btn {
padding: 12px 24px;
border: none;
border-radius: 6px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
margin-right: 10px;
}
.btn-secondary:hover {
background: #5a6268;
}
.btn-success {
background: #28a745;
color: white;
margin-right: 10px;
}
.btn-success:hover {
background: #218838;
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(40, 167, 69, 0.4);
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn-danger:hover {
background: #c82333;
}
.btn-small {
padding: 6px 12px;
font-size: 0.875rem;
}
/* Duties List */
.duties-list {
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #e0e0e0;
}
.duty-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 6px;
border-left: 4px solid #667eea;
}
.duty-item.qualifying {
border-left-color: #28a745;
background: #f0f9f4;
}
.duty-info {
flex: 1;
}
.duty-date {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.duty-meta {
font-size: 0.875rem;
color: #666;
}
.duty-share {
font-weight: 500;
margin-right: 15px;
}
.badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
margin-left: 8px;
}
.badge-qualifying {
background: #28a745;
color: white;
}
.badge-normal {
background: #6c757d;
color: white;
}
/* Employee List */
.employee-list {
margin-top: 20px;
}
.employee-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 6px;
}
.employee-name {
font-weight: 500;
color: #333;
}
/* Calculation Results */
#calculation-results {
margin-top: 30px;
}
.result-card {
margin-bottom: 30px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border-left: 5px solid #667eea;
}
.result-card h3 {
color: #667eea;
margin-bottom: 15px;
}
.result-summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 20px;
}
.result-item {
background: white;
padding: 15px;
border-radius: 6px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
}
.result-label {
font-size: 0.875rem;
color: #666;
margin-bottom: 5px;
}
.result-value {
font-size: 1.5rem;
font-weight: 600;
color: #333;
}
.result-value.success {
color: #28a745;
}
.result-value.warning {
color: #ffc107;
}
.result-value.danger {
color: #dc3545;
}
.bonus-total {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 8px;
text-align: center;
margin-top: 20px;
}
.bonus-total h4 {
font-size: 1rem;
margin-bottom: 10px;
opacity: 0.9;
}
.bonus-total .amount {
font-size: 2.5rem;
font-weight: 700;
}
.threshold-warning {
background: #fff3cd;
border: 2px solid #ffc107;
border-radius: 6px;
padding: 15px;
margin: 20px 0;
}
.threshold-warning h4 {
color: #856404;
margin-bottom: 5px;
}
.threshold-warning p {
color: #856404;
margin: 0;
}
/* Settings */
.settings-section {
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
.settings-section:last-child {
border-bottom: none;
}
.info-box {
background: #e7f3ff;
border-left: 4px solid #2196f3;
padding: 20px;
border-radius: 6px;
margin-top: 15px;
}
.info-box h4 {
color: #1976d2;
margin: 15px 0 10px;
}
.info-box h4:first-child {
margin-top: 0;
}
.info-box ul {
margin-left: 20px;
}
.info-box li {
margin-bottom: 5px;
}
.info-box p {
margin-top: 10px;
}
/* Utility Classes */
.text-muted {
color: #6c757d;
font-style: italic;
}
.text-warning {
color: #856404;
font-weight: 500;
}
/* Toast Notification */
.toast {
position: fixed;
bottom: 30px;
right: 30px;
background: #333;
color: white;
padding: 15px 25px;
border-radius: 6px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
transform: translateY(100px);
opacity: 0;
transition: all 0.3s ease;
z-index: 1000;
}
.toast.show {
transform: translateY(0);
opacity: 1;
}
.toast.success {
background: #28a745;
}
.toast.error {
background: #dc3545;
}
.toast.info {
background: #17a2b8;
}
/* Responsive Design */
@media (max-width: 768px) {
body {
padding: 10px;
}
header h1 {
font-size: 1.5rem;
}
.tab-content {
padding: 20px;
}
.month-selector {
grid-template-columns: 1fr;
}
.result-summary {
grid-template-columns: 1fr;
}
.input-group {
flex-direction: column;
}
.tabs {
overflow-x: auto;
}
.tab-btn {
font-size: 0.875rem;
padding: 12px 15px;
}
.bonus-total .amount {
font-size: 2rem;
}
}
/* Print Styles */
@media print {
body {
background: white;
padding: 0;
}
.container {
box-shadow: none;
}
.tabs,
.btn,
.form-group {
display: none;
}
.tab-content {
display: block !important;
padding: 20px 0;
}
}

22
sw.js Normal file
View file

@ -0,0 +1,22 @@
const CACHE_NAME = 'dienstplan-pro-v1';
const ASSETS = [
'./',
'./index.html',
'./styles.css',
'./app.js',
'./calculator.js',
'./holidays.js',
'./storage.js'
];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS))
);
});
self.addEventListener('fetch', (e) => {
e.respondWith(
caches.match(e.request).then((response) => response || fetch(e.request))
);
});

565
test-suite.js Normal file
View file

@ -0,0 +1,565 @@
/**
* Test Suite for Dienstplan Bonusrechner
*/
class TestRunner {
constructor() {
this.tests = [];
this.passed = 0;
this.failed = 0;
}
/**
* Add a test case
*/
test(name, testFn) {
this.tests.push({ name, testFn });
}
/**
* Assert equality
*/
assertEqual(actual, expected, message = '') {
if (actual !== expected) {
throw new Error(`${message}\nErwartet: ${expected}\nErhalten: ${actual}`);
}
}
/**
* Assert approximate equality (for floating point)
*/
assertAlmostEqual(actual, expected, tolerance = 0.01, message = '') {
if (Math.abs(actual - expected) > tolerance) {
throw new Error(`${message}\nErwartet: ${expected}${tolerance})\nErhalten: ${actual}`);
}
}
/**
* Assert true
*/
assertTrue(value, message = '') {
if (!value) {
throw new Error(`${message}\nErwartet: true\nErhalten: ${value}`);
}
}
/**
* Assert false
*/
assertFalse(value, message = '') {
if (value) {
throw new Error(`${message}\nErwartet: false\nErhalten: ${value}`);
}
}
/**
* Run all tests
*/
async runAll() {
this.passed = 0;
this.failed = 0;
const results = [];
for (const test of this.tests) {
try {
await test.testFn(this);
this.passed++;
results.push({
name: test.name,
passed: true,
error: null
});
} catch (error) {
this.failed++;
results.push({
name: test.name,
passed: false,
error: error.message
});
}
}
return results;
}
/**
* Get summary
*/
getSummary() {
return {
total: this.tests.length,
passed: this.passed,
failed: this.failed
};
}
}
// Create test runner instance
const runner = new TestRunner();
// ============================================================================
// Holiday Provider Tests
// ============================================================================
runner.test('HolidayProvider: Neujahr 2025 wird erkannt', (t) => {
const holidays = new HolidayProvider();
const date = new Date('2025-01-01T12:00:00');
t.assertTrue(holidays.isHoliday(date), 'Neujahr sollte als Feiertag erkannt werden');
});
runner.test('HolidayProvider: Normaler Tag wird nicht als Feiertag erkannt', (t) => {
const holidays = new HolidayProvider();
const date = new Date('2025-01-15T12:00:00'); // Mittwoch
t.assertFalse(holidays.isHoliday(date), 'Normaler Tag sollte nicht als Feiertag erkannt werden');
});
runner.test('HolidayProvider: Tag vor Feiertag wird erkannt', (t) => {
const holidays = new HolidayProvider();
const date = new Date('2024-12-31T12:00:00'); // Tag vor Neujahr
t.assertTrue(holidays.isDayBeforeHoliday(date), 'Tag vor Feiertag sollte erkannt werden');
});
runner.test('HolidayProvider: Fronleichnam 2025 korrekt', (t) => {
const holidays = new HolidayProvider();
const date = new Date('2025-06-19T12:00:00');
t.assertTrue(holidays.isHoliday(date), 'Fronleichnam sollte als Feiertag erkannt werden');
t.assertEqual(holidays.getHolidayName(date), 'Fronleichnam', 'Feiertagsname sollte korrekt sein');
});
// ============================================================================
// Calculator Tests - Day Classification
// ============================================================================
runner.test('Calculator: Freitag ist qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const friday = new Date('2025-11-21T12:00:00'); // Freitag
t.assertTrue(calculator.isQualifyingDay(friday), 'Freitag sollte qualifizierend sein');
});
runner.test('Calculator: Samstag ist qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const saturday = new Date('2025-11-22T12:00:00'); // Samstag
t.assertTrue(calculator.isQualifyingDay(saturday), 'Samstag sollte qualifizierend sein');
});
runner.test('Calculator: Sonntag ist qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const sunday = new Date('2025-11-23T12:00:00'); // Sonntag
t.assertTrue(calculator.isQualifyingDay(sunday), 'Sonntag sollte qualifizierend sein');
});
runner.test('Calculator: Montag ist kein qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const monday = new Date('2025-11-24T12:00:00'); // Montag (kein Feiertag)
t.assertFalse(calculator.isQualifyingDay(monday), 'Normaler Montag sollte nicht qualifizierend sein');
});
runner.test('Calculator: Feiertag ist qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const holiday = new Date('2025-05-01T12:00:00'); // Tag der Arbeit
t.assertTrue(calculator.isQualifyingDay(holiday), 'Feiertag sollte qualifizierend sein');
});
runner.test('Calculator: Tag vor Feiertag ist qualifizierender Tag', (t) => {
const holidays = new HolidayProvider();
const calculator = new BonusCalculator(holidays);
const dayBefore = new Date('2025-04-30T12:00:00'); // Tag vor 1. Mai
t.assertTrue(calculator.isQualifyingDay(dayBefore), 'Tag vor Feiertag sollte qualifizierend sein');
});
// ============================================================================
// Calculator Tests - Bonus Calculation
// ============================================================================
runner.test('Berechnung: Unter Schwellenwert (1.0 WE-Tag) = 0€', (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');
});
runner.test('Berechnung: Genau 2.0 WE-Tage = 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');
});
runner.test('Berechnung: 2x halbe WE-Dienste = 0€ (genau Schwelle, nach Abzug 2.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
];
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');
});
runner.test('Berechnung: 3 WE-Tage = 450€', (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€)');
});
runner.test('Berechnung: Normale Tage + WE-Tage gemischt', (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)
];
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€');
});
runner.test('Berechnung: Halbe Dienste korrekt berechnet', (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
];
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€');
});
runner.test('Berechnung: Feiertag + Vortag', (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)
];
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€)');
});
runner.test('Berechnung: Keine Dienste = 0€', (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');
});
// ============================================================================
// Storage Tests
// ============================================================================
runner.test('Storage: Mitarbeiter hinzufügen', (t) => {
const storage = new DataStorage();
storage.clearAll();
const success = storage.addEmployee('Max Mustermann');
t.assertTrue(success, 'Mitarbeiter sollte hinzugefügt werden');
const employees = storage.getEmployees();
t.assertEqual(employees.length, 1, 'Sollte 1 Mitarbeiter haben');
t.assertTrue(employees.includes('Max Mustermann'), 'Mitarbeiter sollte in Liste sein');
});
runner.test('Storage: Doppelter Mitarbeiter wird abgelehnt', (t) => {
const storage = new DataStorage();
storage.clearAll();
storage.addEmployee('Max Mustermann');
const success = storage.addEmployee('Max Mustermann');
t.assertFalse(success, 'Doppelter Mitarbeiter sollte abgelehnt werden');
const employees = storage.getEmployees();
t.assertEqual(employees.length, 1, 'Sollte nur 1 Mitarbeiter haben');
});
runner.test('Storage: Mitarbeiter entfernen', (t) => {
const storage = new DataStorage();
storage.clearAll();
storage.addEmployee('Max Mustermann');
storage.removeEmployee('Max Mustermann');
const employees = storage.getEmployees();
t.assertEqual(employees.length, 0, 'Sollte 0 Mitarbeiter haben');
});
runner.test('Storage: Dienst hinzufügen und abrufen', (t) => {
const storage = new DataStorage();
storage.clearAll();
storage.addEmployee('Max Mustermann');
const date = new Date('2025-11-22T12:00:00');
storage.addDuty('Max Mustermann', 2025, 11, date, 1.0);
const duties = storage.getDutiesForMonth('Max Mustermann', 2025, 11);
t.assertEqual(duties.length, 1, 'Sollte 1 Dienst haben');
t.assertEqual(duties[0].share, 1.0, 'Dienst sollte share 1.0 haben');
});
runner.test('Storage: Dienst aktualisieren (gleicher Tag)', (t) => {
const storage = new DataStorage();
storage.clearAll();
storage.addEmployee('Max Mustermann');
const date = new Date('2025-11-22T12:00:00');
storage.addDuty('Max Mustermann', 2025, 11, date, 1.0);
storage.addDuty('Max Mustermann', 2025, 11, date, 0.5); // Update
const duties = storage.getDutiesForMonth('Max Mustermann', 2025, 11);
t.assertEqual(duties.length, 1, 'Sollte nur 1 Dienst haben (aktualisiert)');
t.assertEqual(duties[0].share, 0.5, 'Share sollte aktualisiert sein');
});
runner.test('Storage: Mehrere Mitarbeiter', (t) => {
const storage = new DataStorage();
storage.clearAll();
storage.addEmployee('Max Mustermann');
storage.addEmployee('Anna Schmidt');
storage.addEmployee('Peter Müller');
const employees = storage.getEmployees();
t.assertEqual(employees.length, 3, 'Sollte 3 Mitarbeiter haben');
t.assertTrue(employees.includes('Anna Schmidt'), 'Anna Schmidt sollte vorhanden sein');
});
runner.test('Storage: Export und Import', (t) => {
const storage1 = new DataStorage();
storage1.clearAll();
storage1.addEmployee('Max Mustermann');
const date = new Date('2025-11-22T12:00:00');
storage1.addDuty('Max Mustermann', 2025, 11, date, 1.0);
const exported = storage1.exportData();
const storage2 = new DataStorage();
storage2.clearAll();
const success = storage2.importData(exported);
t.assertTrue(success, 'Import sollte erfolgreich sein');
const employees = storage2.getEmployees();
t.assertEqual(employees.length, 1, 'Sollte 1 Mitarbeiter haben');
const duties = storage2.getDutiesForMonth('Max Mustermann', 2025, 11);
t.assertEqual(duties.length, 1, 'Sollte 1 Dienst haben');
});
// ============================================================================
// Edge Cases & Regression Tests
// ============================================================================
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 }
];
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');
});
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({
date: new Date(`2025-11-${String(i).padStart(2, '0')}T12:00:00`),
share: i % 2 === 0 ? 1.0 : 0.5
});
}
const start = Date.now();
const result = calculator.calculateMonthlyBonus(duties);
const duration = Date.now() - start;
t.assertTrue(duration < 100, `Berechnung sollte schnell sein (${duration}ms)`);
t.assertTrue(result.totalBonus > 0, 'Sollte Bonus berechnen');
});
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)
];
// Sollte nicht crashen
const result = calculator.calculateMonthlyBonus(duties);
t.assertEqual(result.normalDays, 1.0, 'Sollte normalen Tag erkennen');
});
// ============================================================================
// Display Functions
// ============================================================================
async function runAllTests() {
const resultsContainer = document.getElementById('test-results');
const summaryDiv = document.getElementById('summary');
const runButton = document.getElementById('run-tests');
// Clear previous results
resultsContainer.innerHTML = '<p>Tests laufen...</p>';
runButton.disabled = true;
// Run tests
const results = await runner.runAll();
const summary = runner.getSummary();
// Update summary
document.getElementById('total-tests').textContent = summary.total;
document.getElementById('passed-tests').textContent = summary.passed;
document.getElementById('failed-tests').textContent = summary.failed;
summaryDiv.style.display = 'flex';
// Display results
resultsContainer.innerHTML = '';
// Group by category
const categories = {
'Holiday Provider': [],
'Calculator - Tag-Klassifizierung': [],
'Calculator - Bonusberechnung': [],
'Storage': [],
'Edge Cases': []
};
results.forEach(result => {
if (result.name.includes('HolidayProvider')) {
categories['Holiday Provider'].push(result);
} else if (result.name.includes('qualifizierender Tag') || result.name.includes('Feiertag ist')) {
categories['Calculator - Tag-Klassifizierung'].push(result);
} else if (result.name.includes('Berechnung:')) {
categories['Calculator - Bonusberechnung'].push(result);
} else if (result.name.includes('Storage:')) {
categories['Storage'].push(result);
} else if (result.name.includes('Edge Case:')) {
categories['Edge Cases'].push(result);
}
});
// Render categories
for (const [category, tests] of Object.entries(categories)) {
if (tests.length === 0) continue;
const suiteDiv = document.createElement('div');
suiteDiv.className = 'test-suite';
const title = document.createElement('h2');
title.textContent = `${category} (${tests.filter(t => t.passed).length}/${tests.length})`;
suiteDiv.appendChild(title);
tests.forEach(result => {
const testDiv = document.createElement('div');
testDiv.className = `test-case ${result.passed ? 'pass' : 'fail'}`;
const nameDiv = document.createElement('div');
nameDiv.className = 'test-name';
nameDiv.textContent = `${result.passed ? '✅' : '❌'} ${result.name}`;
testDiv.appendChild(nameDiv);
if (!result.passed && result.error) {
const errorDiv = document.createElement('div');
errorDiv.className = 'error-details';
errorDiv.textContent = result.error;
testDiv.appendChild(errorDiv);
}
suiteDiv.appendChild(testDiv);
});
resultsContainer.appendChild(suiteDiv);
}
runButton.disabled = false;
// Scroll to summary
summaryDiv.scrollIntoView({ behavior: 'smooth' });
}
// Auto-run on load (optional)
// window.addEventListener('load', runAllTests);

147
test.html Normal file
View file

@ -0,0 +1,147 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dienstplan Test Suite</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}
h1 {
color: #667eea;
}
.test-suite {
background: white;
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.test-case {
padding: 10px;
margin: 5px 0;
border-left: 4px solid #ccc;
background: #f9f9f9;
}
.test-case.pass {
border-left-color: #28a745;
background: #f0f9f4;
}
.test-case.fail {
border-left-color: #dc3545;
background: #fff0f0;
}
.test-name {
font-weight: 600;
margin-bottom: 5px;
}
.test-details {
font-size: 0.9em;
color: #666;
font-family: monospace;
}
.summary {
display: flex;
gap: 20px;
margin-bottom: 20px;
}
.summary-item {
flex: 1;
padding: 20px;
border-radius: 8px;
text-align: center;
color: white;
font-size: 1.2em;
}
.summary-item.total {
background: #667eea;
}
.summary-item.passed {
background: #28a745;
}
.summary-item.failed {
background: #dc3545;
}
.summary-item .label {
font-size: 0.8em;
opacity: 0.9;
}
.summary-item .value {
font-size: 2em;
font-weight: bold;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 1em;
cursor: pointer;
margin-bottom: 20px;
}
button:hover {
opacity: 0.9;
}
.error-details {
background: #fff0f0;
border: 1px solid #dc3545;
padding: 10px;
margin-top: 10px;
border-radius: 4px;
font-family: monospace;
font-size: 0.85em;
color: #721c24;
}
</style>
</head>
<body>
<h1>🧪 Dienstplan Bonusrechner - Test Suite</h1>
<button id="run-tests" onclick="runAllTests()">Alle Tests ausführen</button>
<div class="summary" id="summary" style="display: none;">
<div class="summary-item total">
<div class="label">Gesamt</div>
<div class="value" id="total-tests">0</div>
</div>
<div class="summary-item passed">
<div class="label">Bestanden</div>
<div class="value" id="passed-tests">0</div>
</div>
<div class="summary-item failed">
<div class="label">Fehlgeschlagen</div>
<div class="value" id="failed-tests">0</div>
</div>
</div>
<div id="test-results"></div>
<script src="holidays.js"></script>
<script src="calculator.js"></script>
<script src="storage.js"></script>
<script src="test-suite.js"></script>
</body>
</html>