diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0392ea3 --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 79f4c4f..fc54f13 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app.js b/app.js index 2e8497c..312c812 100644 --- a/app.js +++ b/app.js @@ -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) { ${escapeHtml(day.date)} ${escapeHtml(day.weekday || '')} + ${escapeHtml(day.holiday || '')} ${escapeHtml(day.start || '')} ${escapeHtml(day.end || '')} ${formatDuration(day.workMinutes)} @@ -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'); diff --git a/index.html b/index.html index cf51861..440627a 100644 --- a/index.html +++ b/index.html @@ -12,13 +12,24 @@

CSV-Auswertung

Arbeitszeit-Saldo berechnen

-

CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen: Montag bis Freitag mit 8:00 Stunden Sollzeit, Samstag/Sonntag mit 0:00 Sollzeit.

+

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.

+
@@ -82,6 +93,7 @@ Datum Wochentag + Feiertag Beginn Ende Arbeitszeit diff --git a/style.css b/style.css index 399cc62..891fabc 100644 --- a/style.css +++ b/style.css @@ -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;