commit 16b521f6d93bbe07b3f9f9515eb7b1cfd2212ea5 Author: mike Date: Thu Jun 18 15:18:40 2026 +0200 Release v0.30.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app.js b/app.js new file mode 100644 index 0000000..2e8497c --- /dev/null +++ b/app.js @@ -0,0 +1,391 @@ +const fileInput = document.getElementById('csvFile'); +const dropZone = document.getElementById('dropZone'); +const targetTimeInput = document.getElementById('targetTime'); +const errorBox = document.getElementById('errorBox'); +const summary = document.getElementById('summary'); +const results = document.getElementById('results'); +const monthTable = document.getElementById('monthTable'); +const dayTable = document.getElementById('dayTable'); +const exportButton = document.getElementById('exportCsv'); +const formatHint = document.getElementById('formatHint'); + +let lastResult = null; +let lastFileText = null; + +fileInput.addEventListener('change', event => { + const file = event.target.files[0]; + if (file) readFile(file); +}); + +targetTimeInput.addEventListener('change', () => { + if (lastFileText) analyseCsv(lastFileText); +}); + +exportButton.addEventListener('click', () => { + if (lastResult) exportResultCsv(lastResult); +}); + +['dragenter', 'dragover'].forEach(eventName => { + dropZone.addEventListener(eventName, event => { + event.preventDefault(); + dropZone.classList.add('dragover'); + }); +}); + +['dragleave', 'drop'].forEach(eventName => { + dropZone.addEventListener(eventName, event => { + event.preventDefault(); + dropZone.classList.remove('dragover'); + }); +}); + +dropZone.addEventListener('drop', event => { + const file = event.dataTransfer.files[0]; + if (file) readFile(file); +}); + +function readFile(file) { + hideError(); + + if (!file.name.toLowerCase().endsWith('.csv')) { + showError('Bitte eine CSV-Datei auswählen.'); + return; + } + + const reader = new FileReader(); + reader.onload = () => { + lastFileText = String(reader.result || ''); + analyseCsv(lastFileText); + }; + reader.onerror = () => showError('Die Datei konnte nicht gelesen werden.'); + reader.readAsText(file, 'utf-8'); +} + +function analyseCsv(text) { + try { + const rows = parseCsv(text); + if (rows.length < 2) throw new Error('Die CSV enthält keine auswertbaren Daten.'); + + const headers = rows[0].map(h => h.trim().replace(/^\uFEFF/, '')); + const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== '')); + + const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00'); + 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.'; + } else if (detectedFormat === 'monthly') { + days = analyseMonthlyFormat(headers, dataRows, targetMinutes); + formatHint.textContent = 'Format erkannt: Monatsauswertung. Arbeitszeiten werden aus dem Monatsblock gelesen.'; + } else { + throw new Error('Das CSV-Format wurde nicht erkannt. Benötigt wird entweder die Spalte "Tag" oder "Monat" plus "Dauer Arbeitstag".'); + } + + if (!days.length) throw new Error('Es wurden keine Arbeitszeiten in "Dauer Arbeitstag" gefunden.'); + + const months = groupByMonth(days); + const totals = { + days: days.length, + workMinutes: days.reduce((sum, day) => sum + day.workMinutes, 0), + targetMinutes: days.reduce((sum, day) => sum + day.targetMinutes, 0), + saldoMinutes: days.reduce((sum, day) => sum + day.saldoMinutes, 0) + }; + + lastResult = { months, days, totals }; + render(lastResult); + } catch (error) { + showError(error.message); + } +} + +function detectFormat(headers) { + const hasDuration = headers.includes('Dauer Arbeitstag'); + if (!hasDuration) return 'unknown'; + if (headers.includes('Tag')) return 'daily'; + if (headers.includes('Monat')) return 'monthly'; + return 'unknown'; +} + +function analyseDailyFormat(headers, dataRows, targetMinutes) { + const dateIndex = headers.indexOf('Tag'); + const startIndex = headers.indexOf('Arbeitstag Beginn'); + const endIndex = headers.indexOf('Arbeitstag Ende'); + const durationIndex = headers.indexOf('Dauer Arbeitstag'); + const breakIndex = headers.indexOf('Ruhepause eingehalten'); + const warningIndex = headers.indexOf('Warnungen'); + + const days = []; + for (const row of dataRows) { + const date = (row[dateIndex] || '').trim(); + const durationText = row[durationIndex] || ''; + const workMinutes = extractSingleDuration(durationText); + + // Wochenenden, Feiertage und nicht gearbeitete Tage stehen meist als "-" in Dauer Arbeitstag. + if (workMinutes === null) continue; + + days.push({ + date, + month: monthNameFromDate(date), + start: cleanCell(row[startIndex]), + end: cleanCell(row[endIndex]), + workMinutes, + weekday: weekdayNameFromDate(date), + targetMinutes: targetMinutesForDate(date, targetMinutes), + saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes), + breakOk: cleanCell(row[breakIndex]), + warnings: cleanCell(row[warningIndex]) + }); + } + return days; +} + +function analyseMonthlyFormat(headers, dataRows, targetMinutes) { + const monthIndex = headers.indexOf('Monat'); + const durationIndex = headers.indexOf('Dauer Arbeitstag'); + const stampIndex = headers.indexOf('Stempelzeit'); + + const days = []; + for (const row of dataRows) { + const month = row[monthIndex] || 'Unbekannter Monat'; + const durations = extractDurations(row[durationIndex] || ''); + const dates = stampIndex >= 0 ? extractDates(row[stampIndex] || '') : []; + + durations.forEach((workMinutes, index) => { + const date = dates[index] || `Tag ${index + 1}`; + days.push({ + date, + month, + start: '', + end: '', + workMinutes, + weekday: weekdayNameFromDate(date), + targetMinutes: targetMinutesForDate(date, targetMinutes), + saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes), + breakOk: '', + warnings: '' + }); + }); + } + return days; +} + + +function targetMinutesForDate(date, weekdayTargetMinutes) { + const weekday = getWeekdayIndex(date); + // 0 = Sonntag, 6 = Samstag. Rufbereitschafts-/Wochenendarbeit zählt komplett als Pluszeit. + if (weekday === 0 || weekday === 6) return 0; + return weekdayTargetMinutes; +} + +function getWeekdayIndex(date) { + const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) return null; + const day = Number(match[1]); + const month = Number(match[2]) - 1; + const year = Number(match[3]); + return new Date(year, month, day).getDay(); +} + +function weekdayNameFromDate(date) { + const names = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + const index = getWeekdayIndex(date); + return index === null ? '' : names[index]; +} + +function groupByMonth(days) { + const map = new Map(); + for (const day of days) { + const key = day.month || 'Unbekannt'; + if (!map.has(key)) { + map.set(key, { month: key, days: 0, workMinutes: 0, targetMinutes: 0, saldoMinutes: 0 }); + } + const month = map.get(key); + month.days += 1; + month.workMinutes += day.workMinutes; + month.targetMinutes += day.targetMinutes; + month.saldoMinutes += day.saldoMinutes; + } + return [...map.values()]; +} + +function parseCsv(text) { + const rows = []; + let row = []; + let value = ''; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (char === '"') { + if (inQuotes && next === '"') { + value += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === ',' && !inQuotes) { + row.push(value); + value = ''; + continue; + } + + if ((char === '\n' || char === '\r') && !inQuotes) { + if (char === '\r' && next === '\n') i++; + row.push(value); + rows.push(row); + row = []; + value = ''; + continue; + } + + value += char; + } + + if (value.length || row.length) { + row.push(value); + rows.push(row); + } + + return rows; +} + +function extractDurations(text) { + const matches = [...text.matchAll(/=(\d{1,3}):(\d{2})/g)]; + return matches.map(match => Number(match[1]) * 60 + Number(match[2])); +} + +function extractSingleDuration(text) { + const match = String(text).match(/=(\d{1,3}):(\d{2})/); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); +} + +function extractDates(text) { + return [...text.matchAll(/(\d{2}\.\d{2}\.\d{4})\s+\d{2}:\d{2}\s+-/g)].map(match => match[1]); +} + +function monthNameFromDate(date) { + const match = String(date).match(/^(\d{2})\.(\d{2})\.(\d{4})$/); + if (!match) return 'Unbekannt'; + const names = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; + const monthIndex = Number(match[2]) - 1; + return `${names[monthIndex] || 'Unbekannt'} ${match[3]}`; +} + +function cleanCell(value) { + const text = String(value ?? '').trim(); + return text === '-' ? '' : text; +} + +function timeToMinutes(time) { + const [hours, minutes] = time.split(':').map(Number); + return hours * 60 + minutes; +} + +function formatDuration(minutes, signed = false) { + const sign = minutes < 0 ? '-' : signed && minutes > 0 ? '+' : signed ? '±' : ''; + const abs = Math.abs(minutes); + const hours = Math.floor(abs / 60); + const mins = abs % 60; + return `${sign}${hours}:${String(mins).padStart(2, '0')}`; +} + +function saldoClass(minutes) { + if (minutes > 0) return 'positive'; + if (minutes < 0) return 'negative'; + return 'neutral'; +} + +function render(result) { + hideError(); + summary.classList.remove('hidden'); + results.classList.remove('hidden'); + + document.getElementById('totalDays').textContent = result.totals.days; + document.getElementById('totalWork').textContent = formatDuration(result.totals.workMinutes); + document.getElementById('totalTarget').textContent = formatDuration(result.totals.targetMinutes); + + const totalSaldo = document.getElementById('totalSaldo'); + totalSaldo.textContent = formatDuration(result.totals.saldoMinutes, true); + totalSaldo.className = saldoClass(result.totals.saldoMinutes); + + monthTable.innerHTML = result.months.map(month => ` + + ${escapeHtml(month.month)} + ${month.days} + ${formatDuration(month.workMinutes)} + ${formatDuration(month.targetMinutes)} + ${formatDuration(month.saldoMinutes, true)} + + `).join(''); + + dayTable.innerHTML = result.days.map(day => ` + + ${escapeHtml(day.date)} + ${escapeHtml(day.weekday || '')} + ${escapeHtml(day.start || '')} + ${escapeHtml(day.end || '')} + ${formatDuration(day.workMinutes)} + ${formatDuration(day.targetMinutes)} + ${formatDuration(day.saldoMinutes, true)} + ${escapeHtml(day.breakOk || '')} + ${escapeHtml(day.warnings || '')} + + `).join(''); +} + +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)], + [], + ['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]) + ]; + + const csv = rows.map(row => row.map(csvEscape).join(';')).join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'arbeitszeit-auswertung.csv'; + link.click(); + URL.revokeObjectURL(url); +} + +function csvEscape(value) { + const text = String(value ?? ''); + if (/[;"\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`; + return text; +} + +function showError(message) { + errorBox.textContent = message; + errorBox.classList.remove('hidden'); + summary.classList.add('hidden'); + results.classList.add('hidden'); +} + +function hideError() { + errorBox.textContent = ''; + errorBox.classList.add('hidden'); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..cf51861 --- /dev/null +++ b/index.html @@ -0,0 +1,103 @@ + + + + + + Arbeitszeit-Auswertung + + + +
+
+
+

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 hier ablegen + oder Datei auswählen +
+
+ +
+ + + + + + +
+ + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..399cc62 --- /dev/null +++ b/style.css @@ -0,0 +1,229 @@ +:root { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: #172033; + background: #f3f6fb; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + min-height: 100vh; +} + +.app { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 40px 0; +} + +.hero { + display: flex; + justify-content: space-between; + gap: 24px; + align-items: end; + margin-bottom: 24px; +} + +.eyebrow { + margin: 0 0 8px; + color: #566277; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + font-size: .8rem; +} + +h1 { + margin: 0; + font-size: clamp(2rem, 4vw, 3.2rem); +} + +.intro { + max-width: 700px; + color: #566277; + line-height: 1.6; +} + +.settings { + background: white; + border: 1px solid #dde5f2; + border-radius: 18px; + padding: 16px; + box-shadow: 0 12px 30px rgba(29, 45, 68, .08); +} + +.settings label { + display: grid; + gap: 8px; + font-size: .9rem; + color: #566277; +} + +.settings input { + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 10px 12px; + font: inherit; + color: #172033; +} + +.settings small { + color: #566277; + line-height: 1.4; +} + +.drop-zone { + position: relative; + display: grid; + place-items: center; + min-height: 190px; + border: 2px dashed #9aa8bd; + border-radius: 24px; + background: white; + cursor: pointer; + transition: .2s ease; +} + +.drop-zone.dragover { + border-color: #2f6fed; + background: #eef4ff; +} + +.drop-zone input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.drop-content { + display: grid; + gap: 8px; + text-align: center; + color: #566277; +} + +.drop-content strong { + color: #172033; + font-size: 1.25rem; +} + +.summary { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin: 24px 0; +} + +.card { + background: white; + border: 1px solid #dde5f2; + border-radius: 20px; + padding: 20px; + box-shadow: 0 12px 30px rgba(29, 45, 68, .08); +} + +.card span { + display: block; + color: #566277; + font-size: .9rem; + margin-bottom: 8px; +} + +.card strong { + display: block; + font-size: 1.8rem; +} + +.positive { color: #137333; } +.negative { color: #b3261e; } +.neutral { color: #172033; } + +.results { + background: white; + border: 1px solid #dde5f2; + border-radius: 24px; + padding: 24px; + box-shadow: 0 12px 30px rgba(29, 45, 68, .08); +} + +.table-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + margin-bottom: 16px; +} + +.table-header h2 { margin: 0; } + +button { + border: 0; + border-radius: 12px; + background: #172033; + color: white; + padding: 10px 14px; + font: inherit; + cursor: pointer; +} + +.table-wrap { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + min-width: 680px; +} + +th, td { + padding: 12px 10px; + border-bottom: 1px solid #e6edf7; + text-align: left; + white-space: nowrap; +} + +th { + color: #566277; + font-size: .85rem; + text-transform: uppercase; + letter-spacing: .04em; +} + +.details { + margin-top: 24px; +} + +.details summary { + cursor: pointer; + font-weight: 700; + margin-bottom: 16px; +} + +.error { + margin-top: 16px; + padding: 14px 16px; + border-radius: 14px; + background: #fff1f0; + border: 1px solid #ffccc7; + color: #8a1f11; +} + +.hidden { display: none; } + +@media (max-width: 820px) { + .hero { display: grid; align-items: start; } + .summary { grid-template-columns: 1fr 1fr; } +} + +@media (max-width: 520px) { + .summary { grid-template-columns: 1fr; } +} + +.hint { + color: #566277; + margin: 16px 0 0; + font-size: .95rem; +}