const APP_VERSION = 'v0.41.1';
const fileInput = document.getElementById('csvFile');
const dropZone = document.getElementById('dropZone');
const targetTimeInput = document.getElementById('targetTime');
const federalStateInput = document.getElementById('federalState');
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');
const appVersion = document.getElementById('appVersion');
if (appVersion) appVersion.textContent = APP_VERSION;
let lastResult = null;
let lastFileText = null;
fileInput.addEventListener('change', event => {
const file = event.target.files[0];
if (file) readFile(file);
});
targetTimeInput.addEventListener('change', rerunAnalysis);
federalStateInput.addEventListener('change', rerunAnalysis);
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 rerunAnalysis() {
if (lastFileText) analyseCsv(lastFileText);
}
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) {
hideError();
const rows = parseCsv(text);
if (rows.length < 2) {
showError('Die CSV-Datei enthält keine auswertbaren Daten.');
return;
}
const headers = rows[0].map(normalizeHeader);
const dataRows = rows.slice(1).filter(row => row.some(cell => String(cell || '').trim() !== ''));
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
});
}
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;
});
return [...months.values()].sort((a, b) => a.key.localeCompare(b.key));
}
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;
}
if (cell.length || row.length) {
row.push(cell);
rows.push(row);
}
return rows;
}
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 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_MARIA: 'Bayern inkl. Mariä Himmelfahrt',
NI: 'Niedersachsen',
HH: 'Hamburg'
};
return labels[state] || state;
}
function getHolidayName(date, state) {
const year = date.getFullYear();
const key = dateKey(date);
const easter = easterSunday(year);
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']
]);
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) {
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) - 1;
const day = ((h + l - 7 * m + 114) % 31) + 1;
return new Date(year, month, day);
}
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
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 ?? '');
return `"${text.replaceAll('"', '""')}"`;
}
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>'"]/g, char => ({
'&': '&',
'<': '<',
'>': '>',
"'": ''',
'"': '"'
}[char]));
}
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 throwCsvError(message) {
showError(message);
throw new Error(message);
}