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.
-
-
-
- Sollzeit pro Arbeitstag
-
- Gilt für Montag bis Freitag. Samstag/Sonntag und Feiertage = 0:00.
-
-
- Bundesland / Feiertage
-
- Nordrhein-Westfalen
- Bayern
- Bayern inkl. Mariä Himmelfahrt
- Niedersachsen
- Hamburg
-
- Feiertage werden automatisch aus dem Datum berechnet.
-
+
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.
+
+
-
-
+
+
Arbeitstage
@@ -76,6 +75,7 @@
Monat
Arbeitstage
+ Feiertage
Arbeitszeit
Sollzeit
Saldo
@@ -93,12 +93,12 @@
Datum
Wochentag
- Feiertag
Beginn
Ende
Arbeitszeit
Sollzeit
Saldo
+ Feiertag
Ruhepause
Warnungen
@@ -110,6 +110,13 @@
+
+
+