From 2c24b4d71db1ad7ef7a88301d43c6cd2f7ebce6a Mon Sep 17 00:00:00 2001 From: mike Date: Fri, 19 Jun 2026 15:19:15 +0200 Subject: [PATCH] Release v0.41.1 Versionsanzeige als Footer --- CHANGELOG.md | 27 +- README.md | 10 +- app.js | 704 ++++++++++++++++++++++++--------------------------- index.html | 55 ++-- style.css | 166 ++++++++---- 5 files changed, 500 insertions(+), 462 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0392ea3..4087a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,20 @@ # Changelog +## v0.41.1 + +- Versionsanzeige in einen dezenten Footer rechts unten verschoben +- Repository-Link ergänzt +- Version zentral über `APP_VERSION` gesetzt + +## v0.41.0 + +- Versionsanzeige im Tool ergänzt +- CSV-Export enthält Tool-Version +- Exportdateiname enthält Tool-Version + ## 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 +- Feiertagslogik für Nordrhein-Westfalen, Bayern, Niedersachsen und Hamburg ergänzt +- Zusatzoption Bayern inkl. Mariä Himmelfahrt - 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 +- Tagesdetails und Export um Feiertagsinformationen ergänzt diff --git a/README.md b/README.md index fc54f13..1fc026e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden. +## Version + +Aktueller Stand: v0.41.1 + ## Funktionen - CSV-Upload im Browser @@ -13,8 +17,12 @@ Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- un - Zusatzoption: Bayern inkl. Mariä Himmelfahrt - Monatsübersicht - Tagesdetails inklusive Wochentag und Feiertag -- CSV-Export +- CSV-Export inklusive Tool-Version ## Datenschutz Die CSV-Datei wird ausschließlich lokal im Browser verarbeitet. Es findet kein Upload auf einen Server statt. + +## Repository + +https://git.mike-lindner.net/mike/qplanner-arbeitszeit-auswertung diff --git a/app.js b/app.js index 312c812..64aab18 100644 --- a/app.js +++ b/app.js @@ -1,7 +1,9 @@ +const APP_VERSION = 'v0.41.1'; + const fileInput = document.getElementById('csvFile'); const dropZone = document.getElementById('dropZone'); const targetTimeInput = document.getElementById('targetTime'); -const federalStateSelect = document.getElementById('federalState'); +const federalStateInput = document.getElementById('federalState'); const errorBox = document.getElementById('errorBox'); const summary = document.getElementById('summary'); const results = document.getElementById('results'); @@ -9,6 +11,9 @@ const monthTable = document.getElementById('monthTable'); const dayTable = document.getElementById('dayTable'); const exportButton = document.getElementById('exportCsv'); const formatHint = document.getElementById('formatHint'); +const appVersion = document.getElementById('appVersion'); + +if (appVersion) appVersion.textContent = APP_VERSION; let lastResult = null; let lastFileText = null; @@ -18,13 +23,8 @@ fileInput.addEventListener('change', event => { if (file) readFile(file); }); -targetTimeInput.addEventListener('change', () => { - if (lastFileText) analyseCsv(lastFileText); -}); - -federalStateSelect.addEventListener('change', () => { - if (lastFileText) analyseCsv(lastFileText); -}); +targetTimeInput.addEventListener('change', rerunAnalysis); +federalStateInput.addEventListener('change', rerunAnalysis); exportButton.addEventListener('click', () => { if (lastResult) exportResultCsv(lastResult); @@ -49,6 +49,10 @@ dropZone.addEventListener('drop', event => { if (file) readFile(file); }); +function rerunAnalysis() { + if (lastFileText) analyseCsv(lastFileText); +} + function readFile(file) { hideError(); @@ -67,161 +71,337 @@ function readFile(file) { } function analyseCsv(text) { - try { - const rows = parseCsv(text); - if (rows.length < 2) throw new Error('Die CSV enthält keine auswertbaren Daten.'); + hideError(); - const headers = rows[0].map(h => h.trim().replace(/^\uFEFF/, '')); - const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell).trim() !== '')); + const rows = parseCsv(text); + if (rows.length < 2) { + showError('Die CSV-Datei enthält keine auswertbaren Daten.'); + return; + } - const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00'); - const federalState = federalStateSelect.value || 'NW'; - const detectedFormat = detectFormat(headers); + const headers = rows[0].map(normalizeHeader); + const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim() !== '')); - let days; - if (detectedFormat === 'daily') { - 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, 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".'); + const targetMinutes = timeToMinutes(targetTimeInput.value || '08:00'); + const state = federalStateInput.value || 'NW'; + + const dailyRows = analyseDailyRows(headers, dataRows, targetMinutes, state); + if (!dailyRows.length) { + showError('Es wurden keine Arbeitstage mit auswertbarer Dauer gefunden.'); + return; + } + + const monthlyRows = groupByMonth(dailyRows); + const totals = dailyRows.reduce((acc, row) => { + acc.days += row.workMinutes > 0 ? 1 : 0; + acc.work += row.workMinutes; + acc.target += row.targetMinutes; + acc.saldo += row.saldoMinutes; + acc.holidays += row.isHoliday ? 1 : 0; + return acc; + }, { days: 0, work: 0, target: 0, saldo: 0, holidays: 0 }); + + lastResult = { dailyRows, monthlyRows, totals, state, version: APP_VERSION }; + renderResult(lastResult); +} + +function analyseDailyRows(headers, dataRows, targetMinutes, state) { + const tagIndex = headers.indexOf('tag'); + const beginnIndex = headers.indexOf('arbeitstag beginn'); + const endeIndex = headers.indexOf('arbeitstag ende'); + const dauerIndex = headers.indexOf('dauer arbeitstag'); + const pauseIndex = headers.indexOf('ruhepause eingehalten'); + const warningIndex = headers.indexOf('warnungen'); + + if (tagIndex === -1 || dauerIndex === -1) { + throwCsvError('Das Tagesformat wurde nicht erkannt. Erwartet werden mindestens die Spalten "Tag" und "Dauer Arbeitstag".'); + } + + formatHint.textContent = `Erkanntes Format: Tagesauswertung | Feiertagsregel: ${stateLabel(state)} | Tool-Version: ${APP_VERSION}`; + + return dataRows.map(row => { + const dateText = cleanCell(row[tagIndex]); + const date = parseGermanDate(dateText); + if (!date) return null; + + const durationText = cleanCell(row[dauerIndex]); + const workMinutes = extractDurationMinutes(durationText); + if (workMinutes === null) return null; + + const weekdayNumber = date.getDay(); + const isWeekend = weekdayNumber === 0 || weekdayNumber === 6; + const holidayName = getHolidayName(date, state); + const isHoliday = Boolean(holidayName); + const dayTargetMinutes = isWeekend || isHoliday ? 0 : targetMinutes; + const saldoMinutes = workMinutes - dayTargetMinutes; + + return { + date, + dateText, + monthKey: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`, + monthLabel: date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }), + weekday: weekdayLabel(date), + begin: cleanCell(row[beginnIndex]) || '-', + end: cleanCell(row[endeIndex]) || '-', + workMinutes, + targetMinutes: dayTargetMinutes, + saldoMinutes, + holidayName: holidayName || '-', + isHoliday, + isWeekend, + pause: cleanCell(row[pauseIndex]) || '-', + warnings: cleanCell(row[warningIndex]) || '-' + }; + }).filter(Boolean); +} + +function groupByMonth(dailyRows) { + const months = new Map(); + + dailyRows.forEach(row => { + if (!months.has(row.monthKey)) { + months.set(row.monthKey, { + key: row.monthKey, + label: row.monthLabel, + days: 0, + holidays: 0, + work: 0, + target: 0, + saldo: 0 + }); } - if (!days.length) throw new Error('Es wurden keine Arbeitszeiten in "Dauer Arbeitstag" gefunden.'); + const month = months.get(row.monthKey); + month.days += row.workMinutes > 0 ? 1 : 0; + month.holidays += row.isHoliday ? 1 : 0; + month.work += row.workMinutes; + month.target += row.targetMinutes; + month.saldo += row.saldoMinutes; + }); - 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) - }; + return [...months.values()].sort((a, b) => a.key.localeCompare(b.key)); +} - lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) }; - render(lastResult); - } catch (error) { - showError(error.message); +function renderResult(result) { + summary.classList.remove('hidden'); + results.classList.remove('hidden'); + + document.getElementById('totalDays').textContent = String(result.totals.days); + document.getElementById('totalWork').textContent = minutesToHours(result.totals.work); + document.getElementById('totalTarget').textContent = minutesToHours(result.totals.target); + + const saldoElement = document.getElementById('totalSaldo'); + saldoElement.textContent = minutesToSignedHours(result.totals.saldo); + saldoElement.className = saldoClass(result.totals.saldo); + + monthTable.innerHTML = result.monthlyRows.map(month => ` + + ${escapeHtml(month.label)} + ${month.days} + ${month.holidays} + ${minutesToHours(month.work)} + ${minutesToHours(month.target)} + ${minutesToSignedHours(month.saldo)} + + `).join(''); + + dayTable.innerHTML = result.dailyRows.map(row => ` + + ${escapeHtml(row.dateText)} + ${escapeHtml(row.weekday)} + ${escapeHtml(row.begin)} + ${escapeHtml(row.end)} + ${minutesToHours(row.workMinutes)} + ${minutesToHours(row.targetMinutes)} + ${minutesToSignedHours(row.saldoMinutes)} + ${escapeHtml(row.holidayName)} + ${escapeHtml(row.pause)} + ${escapeHtml(row.warnings)} + + `).join(''); +} + +function exportResultCsv(result) { + const lines = [ + ['Tool-Version', APP_VERSION], + ['Bundesland', stateLabel(result.state)], + [], + ['Datum', 'Wochentag', 'Beginn', 'Ende', 'Arbeitszeit', 'Sollzeit', 'Saldo', 'Feiertag', 'Ruhepause', 'Warnungen'] + ]; + + result.dailyRows.forEach(row => { + lines.push([ + row.dateText, + row.weekday, + row.begin, + row.end, + minutesToHours(row.workMinutes), + minutesToHours(row.targetMinutes), + minutesToSignedHours(row.saldoMinutes), + row.holidayName, + row.pause, + row.warnings + ]); + }); + + const csv = lines.map(line => line.map(csvEscape).join(';')).join('\n'); + const blob = new Blob([`\ufeff${csv}`], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `qplanner-arbeitszeit-auswertung-${APP_VERSION}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +function parseCsv(text) { + const rows = []; + let row = []; + let cell = ''; + 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 === '"') { + cell += '"'; + i++; + } else { + inQuotes = !inQuotes; + } + continue; + } + + if (char === ',' && !inQuotes) { + row.push(cell); + cell = ''; + continue; + } + + if ((char === '\n' || char === '\r') && !inQuotes) { + if (char === '\r' && next === '\n') i++; + row.push(cell); + rows.push(row); + row = []; + cell = ''; + continue; + } + + cell += char; } -} -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, federalState) { - 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), - holiday: holidayNameForDate(date, federalState), - targetMinutes: targetMinutesForDate(date, targetMinutes, federalState), - saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState), - breakOk: cleanCell(row[breakIndex]), - warnings: cleanCell(row[warningIndex]) - }); + if (cell.length || row.length) { + row.push(cell); + rows.push(row); } - return days; + + return rows; } -function analyseMonthlyFormat(headers, dataRows, targetMinutes, federalState) { - 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), - holiday: holidayNameForDate(date, federalState), - targetMinutes: targetMinutesForDate(date, targetMinutes, federalState), - saldoMinutes: workMinutes - targetMinutesForDate(date, targetMinutes, federalState), - breakOk: '', - warnings: '' - }); - }); - } - return days; +function extractDurationMinutes(text) { + if (!text || text === '-') return null; + const match = text.match(/(?:=|^)(\d{1,3}):(\d{2})/); + if (!match) return null; + return Number(match[1]) * 60 + Number(match[2]); } - -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 parseGermanDate(value) { + const match = String(value || '').trim().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]); + const date = new Date(year, month, day); + if (date.getFullYear() !== year || date.getMonth() !== month || date.getDate() !== day) return null; + return date; } +function timeToMinutes(time) { + const [hours, minutes] = String(time || '08:00').split(':').map(Number); + return (hours || 0) * 60 + (minutes || 0); +} + +function minutesToHours(minutes) { + const abs = Math.abs(minutes); + const hours = Math.floor(abs / 60); + const mins = abs % 60; + return `${hours}:${String(mins).padStart(2, '0')}`; +} + +function minutesToSignedHours(minutes) { + if (minutes === 0) return '±0:00'; + return `${minutes > 0 ? '+' : '-'}${minutesToHours(minutes)}`; +} + +function saldoClass(minutes) { + if (minutes > 0) return 'positive'; + if (minutes < 0) return 'negative'; + return 'neutral'; +} + +function cleanCell(value) { + const text = String(value ?? '').trim(); + return text === '' ? '-' : text; +} + +function normalizeHeader(value) { + return String(value || '').trim().toLowerCase(); +} + +function weekdayLabel(date) { + return date.toLocaleDateString('de-DE', { weekday: 'long' }); +} function stateLabel(state) { const labels = { NW: 'Nordrhein-Westfalen', BY: 'Bayern', - BY_MH: 'Bayern inkl. Mariä Himmelfahrt', + BY_MARIA: 'Bayern inkl. Mariä Himmelfahrt', NI: 'Niedersachsen', HH: 'Hamburg' }; - return labels[state] || 'Nordrhein-Westfalen'; + return labels[state] || state; } -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 getHolidayName(date, state) { + const year = date.getFullYear(); + const key = dateKey(date); + const easter = easterSunday(year); -function dateKeyFromDate(year, month, day) { - return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; -} + const holidays = new Map([ + [`${year}-01-01`, 'Neujahr'], + [`${year}-05-01`, 'Tag der Arbeit'], + [`${year}-10-03`, 'Tag der Deutschen Einheit'], + [`${year}-12-25`, '1. Weihnachtstag'], + [`${year}-12-26`, '2. Weihnachtstag'], + [dateKey(addDays(easter, -2)), 'Karfreitag'], + [dateKey(addDays(easter, 1)), 'Ostermontag'], + [dateKey(addDays(easter, 39)), 'Christi Himmelfahrt'], + [dateKey(addDays(easter, 50)), 'Pfingstmontag'] + ]); -function addDaysKey(date, offset) { - const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate() + offset); - return dateKeyFromDate(copy.getFullYear(), copy.getMonth() + 1, copy.getDate()); + if (state === 'BY' || state === 'BY_MARIA') { + holidays.set(`${year}-01-06`, 'Heilige Drei Könige'); + holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam'); + holidays.set(`${year}-11-01`, 'Allerheiligen'); + if (state === 'BY_MARIA') holidays.set(`${year}-08-15`, 'Mariä Himmelfahrt'); + } + + if (state === 'NW') { + holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam'); + holidays.set(`${year}-11-01`, 'Allerheiligen'); + } + + if (state === 'NI' || state === 'HH') { + holidays.set(`${year}-10-31`, 'Reformationstag'); + } + + return holidays.get(key) || null; } 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; @@ -234,246 +414,34 @@ function easterSunday(year) { 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 month = Math.floor((h + l - 7 * m + 114) / 31) - 1; const day = ((h + l - 7 * m + 114) % 31) + 1; - return new Date(year, month - 1, day); + return new Date(year, month, 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 addDays(date, days) { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; } -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; - 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.holiday || '')} - ${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', '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', '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'); - 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 dateKey(date) { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; } function csvEscape(value) { const text = String(value ?? ''); - if (/[;"\n\r]/.test(text)) return `"${text.replace(/"/g, '""')}"`; - return text; + return `"${text.replaceAll('"', '""')}"`; +} + +function escapeHtml(value) { + return String(value ?? '').replace(/[&<>'"]/g, char => ({ + '&': '&', + '<': '<', + '>': '>', + "'": ''', + '"': '"' + }[char])); } function showError(message) { @@ -488,11 +456,7 @@ function hideError() { errorBox.classList.add('hidden'); } -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); +function throwCsvError(message) { + showError(message); + throw new Error(message); } diff --git a/index.html b/index.html index 440627a..0bd7657 100644 --- a/index.html +++ b/index.html @@ -3,37 +3,36 @@ - Arbeitszeit-Auswertung + qPlanner Arbeitszeit-Auswertung
-

CSV-Auswertung

+

qPlanner 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 und gesetzliche Feiertage mit 0:00 Sollzeit.

-
-
- - +

CSV-Datei hochladen, Netto-Arbeitszeiten auslesen und Plus-/Minusstunden berechnen. Samstage, Sonntage und gesetzliche Feiertage im gewählten Bundesland werden mit 0:00 Stunden Sollzeit bewertet.

+
+ + +
+
@@ -42,10 +41,10 @@
-
- +

+
+ + + diff --git a/style.css b/style.css index 891fabc..50ec4d8 100644 --- a/style.css +++ b/style.css @@ -12,7 +12,7 @@ body { } .app { - width: min(1120px, calc(100% - 32px)); + width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 40px 0; } @@ -40,20 +40,45 @@ h1 { } .intro { - max-width: 700px; + max-width: 760px; color: #566277; line-height: 1.6; } -.settings { +.settings, +.card, +.results { background: white; border: 1px solid #dde5f2; + box-shadow: 0 12px 30px rgba(29, 45, 68, .08); +} + +.version-card { + border-radius: 18px; + padding: 14px 18px; + min-width: 130px; + text-align: center; +} + +.version-card span { + display: block; + color: #566277; + font-size: .82rem; +} + +.version-card strong { + display: block; + margin-top: 4px; + font-size: 1.25rem; +} + +.settings { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 16px; border-radius: 18px; padding: 16px; - min-width: 280px; - display: grid; - gap: 14px; - box-shadow: 0 12px 30px rgba(29, 45, 68, .08); + margin-bottom: 24px; } .settings label { @@ -70,11 +95,7 @@ h1 { padding: 10px 12px; font: inherit; color: #172033; -} - -.settings small { - color: #566277; - line-height: 1.4; + background: white; } .drop-zone { @@ -90,8 +111,8 @@ h1 { } .drop-zone.dragover { - border-color: #2f6fed; - background: #eef4ff; + border-color: #2f6feb; + background: #edf4ff; } .drop-zone input { @@ -113,6 +134,22 @@ h1 { font-size: 1.25rem; } +.error { + margin-top: 18px; + padding: 14px 16px; + border-radius: 14px; + background: #fff1f2; + color: #be123c; + border: 1px solid #fecdd3; +} + +.hidden { display: none !important; } + +.format-hint { + color: #566277; + min-height: 22px; +} + .summary { display: grid; grid-template-columns: repeat(4, 1fr); @@ -121,35 +158,26 @@ h1 { } .card { - background: white; - border: 1px solid #dde5f2; - border-radius: 20px; - padding: 20px; - box-shadow: 0 12px 30px rgba(29, 45, 68, .08); + border-radius: 18px; + padding: 18px; } .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; } +.saldo-card strong.positive { color: #15803d; } +.saldo-card strong.negative { color: #b91c1c; } .results { - background: white; - border: 1px solid #dde5f2; border-radius: 24px; - padding: 24px; - box-shadow: 0 12px 30px rgba(29, 45, 68, .08); + padding: 22px; } .table-header { @@ -160,18 +188,22 @@ h1 { margin-bottom: 16px; } -.table-header h2 { margin: 0; } +.table-header h2 { + margin: 0; +} button { - border: 0; + border: none; border-radius: 12px; + padding: 10px 14px; background: #172033; color: white; - padding: 10px 14px; font: inherit; cursor: pointer; } +button:hover { opacity: .9; } + .table-wrap { overflow-x: auto; } @@ -179,55 +211,79 @@ button { table { width: 100%; border-collapse: collapse; - min-width: 680px; + font-size: .95rem; } -th, td { +th, +td { padding: 12px 10px; - border-bottom: 1px solid #e6edf7; + border-bottom: 1px solid #e5eaf3; text-align: left; white-space: nowrap; } th { color: #566277; - font-size: .85rem; - text-transform: uppercase; - letter-spacing: .04em; + font-weight: 700; + background: #f8fafc; } +tr.holiday-row td { + background: #fff7ed; +} + +tr.weekend-row td { + background: #f8fafc; +} + +.positive { color: #15803d; font-weight: 700; } +.negative { color: #b91c1c; font-weight: 700; } +.neutral { color: #566277; font-weight: 700; } + .details { - margin-top: 24px; + margin-top: 20px; } .details summary { cursor: pointer; font-weight: 700; - margin-bottom: 16px; + margin-bottom: 14px; } -.error { - margin-top: 16px; - padding: 14px 16px; - border-radius: 14px; - background: #fff1f0; - border: 1px solid #ffccc7; - color: #8a1f11; + +.app-footer { + position: fixed; + right: 16px; + bottom: 10px; + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12px; + color: #6b7280; + background: rgba(255, 255, 255, .85); + border: 1px solid #e5eaf3; + border-radius: 999px; + padding: 5px 10px; + box-shadow: 0 6px 18px rgba(29, 45, 68, .08); + backdrop-filter: blur(6px); } -.hidden { display: none; } +.app-footer a { + color: inherit; + text-decoration: none; +} + +.app-footer a:hover { + text-decoration: underline; +} @media (max-width: 820px) { - .hero { display: grid; align-items: start; } + .hero { align-items: start; flex-direction: column; } + .settings { grid-template-columns: 1fr; } .summary { grid-template-columns: 1fr 1fr; } } -@media (max-width: 520px) { +@media (max-width: 540px) { .summary { grid-template-columns: 1fr; } -} - -.hint { - color: #566277; - margin: 16px 0 0; - font-size: .95rem; + .app-footer { right: 8px; bottom: 8px; font-size: 11px; } }