Release v0.40.0 Feiertagslogik

This commit is contained in:
2026-06-19 14:56:50 +02:00
parent faa91e4425
commit 05b0302039
5 changed files with 173 additions and 47 deletions
+17
View File
@@ -0,0 +1,17 @@
# Changelog
## v0.40.0
- Bundesland-Auswahl für Feiertage ergänzt
- Unterstützte Bundesländer: Bayern, Niedersachsen, Hamburg, Nordrhein-Westfalen
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
- Feiertage werden automatisch aus dem Datum berechnet
- Feiertage werden mit 0:00 Sollzeit bewertet
- Arbeit an Feiertagen zählt vollständig als Pluszeit
- Feiertagsspalte in Tagesdetails und CSV-Export ergänzt
## v0.30.0
- Sollzeit Montag bis Freitag = 8:00 Stunden
- Sollzeit Samstag und Sonntag = 0:00 Stunden
- Wochenendarbeit zählt vollständig als Pluszeit
+14 -28
View File
@@ -1,34 +1,20 @@
\# qplanner-arbeitszeit-auswertung
# qplanner-arbeitszeit-auswertung
Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden.
## Funktionen
- CSV-Upload im Browser
- Auswertung der Netto-Arbeitszeit
- Sollzeit Montag bis Freitag: 8:00 Stunden
- Sollzeit Samstag und Sonntag: 0:00 Stunden
- Gesetzliche Feiertage je Bundesland: 0:00 Stunden
- Unterstützte Bundesländer: Bayern, Niedersachsen, Hamburg, Nordrhein-Westfalen
- Zusatzoption: Bayern inkl. Mariä Himmelfahrt
- Monatsübersicht
- Tagesdetails inklusive Wochentag und Feiertag
- CSV-Export
\## Funktionen
\- CSV-Upload im Browser
\- Auswertung der Netto-Arbeitszeit
\- Sollzeit Montag bis Freitag: 8:00 Stunden
\- Sollzeit Samstag und Sonntag: 0:00 Stunden
\- Monatsübersicht
\- Tagesdetails
\- CSV-Export
\## Datenschutz
Die CSV-Datei wird ausschließlich lokal im Browser verarbeitet.
## Datenschutz
Die CSV-Datei wird ausschließlich lokal im Browser verarbeitet. Es findet kein Upload auf einen Server statt.
+123 -16
View File
@@ -1,6 +1,7 @@
const fileInput = document.getElementById('csvFile');
const dropZone = document.getElementById('dropZone');
const targetTimeInput = document.getElementById('targetTime');
const federalStateSelect = document.getElementById('federalState');
const errorBox = document.getElementById('errorBox');
const summary = document.getElementById('summary');
const results = document.getElementById('results');
@@ -21,6 +22,10 @@ targetTimeInput.addEventListener('change', () => {
if (lastFileText) analyseCsv(lastFileText);
});
federalStateSelect.addEventListener('change', () => {
if (lastFileText) analyseCsv(lastFileText);
});
exportButton.addEventListener('click', () => {
if (lastResult) exportResultCsv(lastResult);
});
@@ -70,15 +75,16 @@ function analyseCsv(text) {
const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== ''));
const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00');
const federalState = federalStateSelect.value || 'NW';
const detectedFormat = detectFormat(headers);
let days;
if (detectedFormat === 'daily') {
days = analyseDailyFormat(headers, dataRows, targetMinutes);
formatHint.textContent = 'Format erkannt: Tagesauswertung. Montag bis Freitag haben Sollzeit, Samstag und Sonntag werden mit 0:00 Sollzeit berechnet. Zeilen ohne Arbeitszeit werden ignoriert.';
days = analyseDailyFormat(headers, dataRows, targetMinutes, federalState);
formatHint.textContent = `Format erkannt: Tagesauswertung. Montag bis Freitag haben Sollzeit, Samstag/Sonntag und Feiertage in ${stateLabel(federalState)} werden mit 0:00 Sollzeit berechnet. Zeilen ohne Arbeitszeit werden ignoriert.`;
} else if (detectedFormat === 'monthly') {
days = analyseMonthlyFormat(headers, dataRows, targetMinutes);
formatHint.textContent = 'Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen.';
days = analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState);
formatHint.textContent = `Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen. Wenn ein Datum erkannt wird, werden Wochenenden und Feiertage in ${stateLabel(federalState)} mit 0:00 Sollzeit berechnet.`;
} else {
throw new Error('Das CSV-Format wurde nicht erkannt. Benötigt wird entweder die Spalte "Tag" oder "Monat" plus "Dauer Arbeitstag".');
}
@@ -93,7 +99,7 @@ function analyseCsv(text) {
saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0)
};
lastResult = { months, days, totals };
lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) };
render(lastResult);
} catch (error) {
showError(error.message);
@@ -108,7 +114,7 @@ function detectFormat(headers) {
return 'unknown';
}
function analyseDailyFormat(headers, dataRows, targetMinutes) {
function analyseDailyFormat(headers, dataRows, targetMinutes, federalState) {
const dateIndex = headers.indexOf('Tag');
const startIndex = headers.indexOf('Arbeitstag Beginn');
const endIndex = headers.indexOf('Arbeitstag Ende');
@@ -132,8 +138,9 @@ function analyseDailyFormat(headers, dataRows, targetMinutes) {
end: cleanCell(row[endIndex]),
workMinutes,
weekday: weekdayNameFromDate(date),
targetMinutes: targetMinutesForDate(date, targetMinutes),
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes),
holiday: holidayNameForDate(date, federalState),
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
breakOk: cleanCell(row[breakIndex]),
warnings: cleanCell(row[warningIndex])
});
@@ -141,7 +148,7 @@ function analyseDailyFormat(headers, dataRows, targetMinutes) {
return days;
}
function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) {
const monthIndex = headers.indexOf('Monat');
const durationIndex = headers.indexOf('Dauer Arbeitstag');
const stampIndex = headers.indexOf('Stempelzeit');
@@ -161,8 +168,9 @@ function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
end: '',
workMinutes,
weekday: weekdayNameFromDate(date),
targetMinutes: targetMinutesForDate(date, targetMinutes),
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes),
holiday: holidayNameForDate(date, federalState),
targetMinutes: targetMinutesForDate(date, targetMinutes, federalState),
saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState),
breakOk: '',
warnings: ''
});
@@ -172,13 +180,111 @@ function analyseMonthlyFormat(headers, dataRows, targetMinutes) {
}
function targetMinutesForDate(date, weekdayTargetMinutes) {
function targetMinutesForDate(date, weekdayTargetMinutes, federalState) {
const weekday = getWeekdayIndex(date);
// 0 = Sonntag, 6 = Samstag. Rufbereitschafts-/Wochenendarbeit zählt komplett als Pluszeit.
if (weekday === 0 || weekday === 6) return 0;
if (holidayNameForDate(date, federalState)) return 0;
return weekdayTargetMinutes;
}
function stateLabel(state) {
const labels = {
NW: 'Nordrhein-Westfalen',
BY: 'Bayern',
BY_MH: 'Bayern inkl. Mariä Himmelfahrt',
NI: 'Niedersachsen',
HH: 'Hamburg'
};
return labels[state] || 'Nordrhein-Westfalen';
}
function parseGermanDate(date) {
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return null;
return {
day: Number(match[1]),
month: Number(match[2]),
year: Number(match[3]),
key: `${match[3]}-${match[2]}-${match[1]}`
};
}
function dateKeyFromDate(year, month, day) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
function addDaysKey(date, offset) {
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset);
return dateKeyFromDate(copy.getFullYear(), copy.getMonth() + 1, copy.getDate());
}
function easterSunday(year) {
// Gaußsche Osterformel für den gregorianischen Kalender.
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
function holidaysForYear(year, federalState) {
const easter = easterSunday(year);
const holidays = new Map();
const add = (month, day, name) => holidays.set(dateKeyFromDate(year, month, day), name);
const addRelative = (offset, name) => holidays.set(addDaysKey(easter, offset), name);
// Bundesweite Feiertage
add(1, 1, 'Neujahr');
addRelative(-2, 'Karfreitag');
addRelative(1, 'Ostermontag');
add(5, 1, 'Tag der Arbeit');
addRelative(39, 'Christi Himmelfahrt');
addRelative(50, 'Pfingstmontag');
add(10, 3, 'Tag der Deutschen Einheit');
add(12, 25, '1. Weihnachtstag');
add(12, 26, '2. Weihnachtstag');
if (federalState === 'BY' || federalState === 'BY_MH') {
add(1, 6, 'Heilige Drei Könige');
addRelative(60, 'Fronleichnam');
add(11, 1, 'Allerheiligen');
}
if (federalState === 'BY_MH') {
add(8, 15, 'Mariä Himmelfahrt');
}
if (federalState === 'NW') {
addRelative(60, 'Fronleichnam');
add(11, 1, 'Allerheiligen');
}
if (federalState === 'NI' || federalState === 'HH') {
add(10, 31, 'Reformationstag');
}
return holidays;
}
function holidayNameForDate(date, federalState) {
const parsed = parseGermanDate(date);
if (!parsed) return '';
return holidaysForYear(parsed.year, federalState).get(parsed.key) || '';
}
function getWeekdayIndex(date) {
const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return null;
@@ -330,6 +436,7 @@ function render(result) {
<tr>
<td>${escapeHtml(day.date)}</td>
<td>${escapeHtml(day.weekday || '')}</td>
<td>${escapeHtml(day.holiday || '')}</td>
<td>${escapeHtml(day.start || '')}</td>
<td>${escapeHtml(day.end || '')}</td>
<td>${formatDuration(day.workMinutes)}</td>
@@ -343,14 +450,14 @@ function render(result) {
function exportResultCsv(result) {
const rows = [
['Bereich', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo'],
['Gesamt', '', result.totals.days, formatDuration(result.totals.workMinutes), formatDuration(result.totals.targetMinutes), formatDuration(result.totals.saldoMinutes, true)],
['Bereich', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Bundesland'],
['Gesamt', '', result.totals.days, formatDuration(result.totals.workMinutes), formatDuration(result.totals.targetMinutes), formatDuration(result.totals.saldoMinutes, true), result.federalStateLabel || ''],
[],
['Monat', 'Monat/Datum', 'Arbeitstage', 'Arbeitszeit', 'Sollzeit', 'Saldo'],
...result.months.map(m => ['Monat', m.month, m.days, formatDuration(m.workMinutes), formatDuration(m.targetMinutes), formatDuration(m.saldoMinutes, true)]),
[],
['Tag', 'Datum', 'Wochentag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Ruhepause', 'Warnungen'],
...result.days.map(d => ['Tag', d.date, d.weekday || '', d.start, d.end, formatDuration(d.workMinutes), formatDuration(d.targetMinutes), formatDuration(d.saldoMinutes, true), d.breakOk, d.warnings])
['Tag', 'Datum', 'Wochentag', 'Feiertag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Ruhepause', 'Warnungen'],
...result.days.map(d => ['Tag', d.date, d.weekday || '', d.holiday || '', d.start, d.end, formatDuration(d.workMinutes), formatDuration(d.targetMinutes), formatDuration(d.saldoMinutes, true), d.breakOk, d.warnings])
];
const csv = rows.map(row => row.map(csvEscape).join(';')).join('\n');
+14 -2
View File
@@ -12,13 +12,24 @@
<div>
<p class="eyebrow">CSV-Auswertung</p>
<h1>Arbeitszeit-Saldo berechnen</h1>
<p class="intro">CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen: Montag bis Freitag mit 8:00 Stunden Sollzeit, Samstag/Sonntag mit 0:00 Sollzeit.</p>
<p class="intro">CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen: Montag bis Freitag mit 8:00 Stunden Sollzeit, Samstag/Sonntag und gesetzliche Feiertage mit 0:00 Sollzeit.</p>
</div>
<div class="settings" aria-label="Einstellungen">
<label>
Sollzeit pro Arbeitstag
<input id="targetTime" type="time" value="08:00" step="60">
<small>Gilt für Montag bis Freitag. Samstag/Sonntag = 0:00.</small>
<small>Gilt für Montag bis Freitag. Samstag/Sonntag und Feiertage = 0:00.</small>
</label>
<label>
Bundesland / Feiertage
<select id="federalState">
<option value="NW" selected>Nordrhein-Westfalen</option>
<option value="BY">Bayern</option>
<option value="BY_MH">Bayern inkl. Mariä Himmelfahrt</option>
<option value="NI">Niedersachsen</option>
<option value="HH">Hamburg</option>
</select>
<small>Feiertage werden automatisch aus dem Datum berechnet.</small>
</label>
</div>
</section>
@@ -82,6 +93,7 @@
<tr>
<th>Datum</th>
<th>Wochentag</th>
<th>Feiertag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Arbeitszeit</th>
+5 -1
View File
@@ -50,6 +50,9 @@ h1 {
border: 1px solid #dde5f2;
border-radius: 18px;
padding: 16px;
min-width: 280px;
display: grid;
gap: 14px;
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
}
@@ -60,7 +63,8 @@ h1 {
color: #566277;
}
.settings input {
.settings input,
.settings select {
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px 12px;