4 Commits
Author SHA1 Message Date
mike 0930b37638 CHANGELOG.md aktualisiert 2026-06-30 10:04:46 +00:00
mike c4b22cfe50 README.md aktualisiert 2026-06-30 10:02:09 +00:00
mike 1b4c99c08d Dateien nach "/" hochladen
Release v0.42.0 Modern UI
2026-06-30 09:57:21 +00:00
mike 2c24b4d71d Release v0.41.1 Versionsanzeige als Footer 2026-06-19 15:19:15 +02:00
5 changed files with 873 additions and 540 deletions
+28 -12
View File
@@ -1,17 +1,33 @@
# Changelog
## [v0.42.0] - 2026-06-30
### Neu
- Modernes, komplett überarbeitetes Webinterface
- Verbesserte Benutzerführung
- Optimiertes responsives Layout
- Überarbeitete Karten- und Tabellenansicht
- Modernisierte Farbgebung und Typografie
### Geändert
- README auf Version v0.42.0 aktualisiert
## 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
+10 -1
View File
@@ -2,9 +2,14 @@
Browserbasierte Auswertung von qPlanner CSV-Exporten zur Berechnung von Plus- und Minusstunden.
## Version
Aktueller Stand: **v0.42.0**
## Funktionen
- CSV-Upload im Browser
- Modernes, responsives Webinterface
- Auswertung der Netto-Arbeitszeit
- Sollzeit Montag bis Freitag: 8:00 Stunden
- Sollzeit Samstag und Sonntag: 0:00 Stunden
@@ -13,8 +18,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
+327 -363
View File
@@ -1,7 +1,9 @@
const APP_VERSION = 'v0.42.0';
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 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 federalState = federalStateSelect.value || 'NW';
const detectedFormat = detectFormat(headers);
const state = federalStateInput.value || 'NW';
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 dailyRows = analyseDailyRows(headers, dataRows, targetMinutes, state);
if (!dailyRows.length) {
showError('Es wurden keine Arbeitstage mit auswertbarer Dauer gefunden.');
return;
}
if (!days.length) throw new Error('Es wurden keine Arbeitszeiten in "Dauer Arbeitstag" gefunden.');
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 });
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 = { 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]) || '-'
};
lastResult = { months, days, totals, federalState, federalStateLabel: stateLabel(federalState) };
render(lastResult);
} catch (error) {
showError(error.message);
}
}).filter(Boolean);
}
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 groupByMonth(dailyRows) {
const months = new Map();
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])
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
});
}
return days;
}
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: ''
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 => `
<tr>
<td>${escapeHtml(month.label)}</td>
<td>${month.days}</td>
<td>${month.holidays}</td>
<td>${minutesToHours(month.work)}</td>
<td>${minutesToHours(month.target)}</td>
<td class="${saldoClass(month.saldo)}">${minutesToSignedHours(month.saldo)}</td>
</tr>
`).join('');
dayTable.innerHTML = result.dailyRows.map(row => `
<tr class="${row.isHoliday ? 'holiday-row' : row.isWeekend ? 'weekend-row' : ''}">
<td>${escapeHtml(row.dateText)}</td>
<td>${escapeHtml(row.weekday)}</td>
<td>${escapeHtml(row.begin)}</td>
<td>${escapeHtml(row.end)}</td>
<td>${minutesToHours(row.workMinutes)}</td>
<td>${minutesToHours(row.targetMinutes)}</td>
<td class="${saldoClass(row.saldoMinutes)}">${minutesToSignedHours(row.saldoMinutes)}</td>
<td>${escapeHtml(row.holidayName)}</td>
<td>${escapeHtml(row.pause)}</td>
<td>${escapeHtml(row.warnings)}</td>
</tr>
`).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
]);
});
}
return days;
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;
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;
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_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);
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');
}
function dateKeyFromDate(year, month, day) {
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
if (state === 'NW') {
holidays.set(dateKey(addDays(easter, 60)), 'Fronleichnam');
holidays.set(`${year}-11-01`, 'Allerheiligen');
}
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 === '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');
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
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;
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 => `
<tr>
<td>${escapeHtml(month.month)}</td>
<td>${month.days}</td>
<td>${formatDuration(month.workMinutes)}</td>
<td>${formatDuration(month.targetMinutes)}</td>
<td class="${saldoClass(month.saldoMinutes)}">${formatDuration(month.saldoMinutes, true)}</td>
</tr>
`).join('');
dayTable.innerHTML = result.days.map(day => `
<tr>
<td>${escapeHtml(day.date)}</td>
<td>${escapeHtml(day.weekday || '')}</td>
<td>${escapeHtml(day.holiday || '')}</td>
<td>${escapeHtml(day.start || '')}</td>
<td>${escapeHtml(day.end || '')}</td>
<td>${formatDuration(day.workMinutes)}</td>
<td>${formatDuration(day.targetMinutes)}</td>
<td class="${saldoClass(day.saldoMinutes)}">${formatDuration(day.saldoMinutes, true)}</td>
<td>${escapeHtml(day.breakOk || '')}</td>
<td>${escapeHtml(day.warnings || '')}</td>
</tr>
`).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 => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
"'": '&#39;',
'"': '&quot;'
}[char]));
}
function showError(message) {
@@ -488,11 +456,7 @@ function hideError() {
errorBox.classList.add('hidden');
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
function throwCsvError(message) {
showError(message);
throw new Error(message);
}
+66 -27
View File
@@ -3,72 +3,100 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Arbeitszeit-Auswertung</title>
<title>qPlanner Arbeitszeit-Auswertung</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="topbar">
<div class="brand">
<span class="brand-icon" aria-hidden="true"></span>
<div>
<strong>qPlanner</strong>
<span>Arbeitszeit-Auswertung</span>
</div>
</div>
</header>
<main class="app">
<section class="hero">
<div>
<p class="eyebrow">CSV-Auswertung</p>
<div class="hero-content">
<p class="eyebrow">qPlanner CSV-Auswertung</p>
<h1>Arbeitszeit-Saldo berechnen</h1>
<p class="intro">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.</p>
</div>
<div class="settings" aria-label="Einstellungen">
<label>
Sollzeit pro Arbeitstag
<p class="intro">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.</p>
<section class="settings" aria-label="Einstellungen">
<label class="setting-card">
<span class="setting-icon clock" aria-hidden="true"></span>
<span class="setting-content">
<span class="setting-label">Sollzeit pro Arbeitstag (MoFr)</span>
<input id="targetTime" type="time" value="08:00" step="60">
<small>Gilt für Montag bis Freitag. Samstag/Sonntag und Feiertage = 0:00.</small>
</span>
</label>
<label>
Bundesland / Feiertage
<label class="setting-card">
<span class="setting-icon location" aria-hidden="true"></span>
<span class="setting-content">
<span class="setting-label">Bundesland / Feiertagsregel</span>
<select id="federalState">
<option value="NW" selected>Nordrhein-Westfalen</option>
<option value="NW">Nordrhein-Westfalen</option>
<option value="BY">Bayern</option>
<option value="BY_MH">Bayern inkl. Mariä Himmelfahrt</option>
<option value="BY_MARIA">Bayern inkl. Mariä Himmelfahrt</option>
<option value="NI">Niedersachsen</option>
<option value="HH">Hamburg</option>
</select>
<small>Feiertage werden automatisch aus dem Datum berechnet.</small>
</span>
</label>
</div>
</section>
</div>
<section id="dropZone" class="drop-zone">
<section id="dropZone" class="drop-zone" aria-label="CSV-Datei hochladen">
<input id="csvFile" type="file" accept=".csv,text/csv">
<div class="drop-content">
<span class="upload-icon" aria-hidden="true"></span>
<strong>CSV-Datei hier ablegen</strong>
<span>oder Datei auswählen</span>
<span>oder klicken zum Auswählen</span>
<em>Unterstützt: .csv Dateien</em>
</div>
</section>
<section id="formatHint" class="hint"></section>
</section>
<section id="errorBox" class="error hidden"></section>
<p id="formatHint" class="format-hint"></p>
<section id="summary" class="summary hidden" aria-live="polite">
<article class="card">
<article class="stat-card stat-days">
<span class="stat-icon" aria-hidden="true"></span>
<span>Arbeitstage</span>
<strong id="totalDays">0</strong>
<small>Tage mit Arbeitszeit</small>
</article>
<article class="card">
<article class="stat-card stat-work">
<span class="stat-icon" aria-hidden="true"></span>
<span>Arbeitszeit gesamt</span>
<strong id="totalWork">0:00</strong>
<small>Stunden</small>
</article>
<article class="card">
<article class="stat-card stat-target">
<span class="stat-icon" aria-hidden="true"></span>
<span>Sollzeit gesamt</span>
<strong id="totalTarget">0:00</strong>
<small>Stunden</small>
</article>
<article class="card saldo-card">
<article class="stat-card stat-saldo">
<span class="stat-icon" aria-hidden="true"></span>
<span>Saldo gesamt</span>
<strong id="totalSaldo">±0:00</strong>
<small>Stunden</small>
</article>
</section>
<section id="results" class="results hidden">
<div class="panel table-panel">
<div class="table-header">
<div class="section-title">
<span aria-hidden="true"></span>
<h2>Monatsübersicht</h2>
<button id="exportCsv" type="button">Ergebnis als CSV exportieren</button>
</div>
<button id="exportCsv" type="button">⇩ Ergebnis als CSV exportieren</button>
</div>
<div class="table-wrap">
<table>
@@ -76,6 +104,7 @@
<tr>
<th>Monat</th>
<th>Arbeitstage</th>
<th>Feiertage</th>
<th>Arbeitszeit</th>
<th>Sollzeit</th>
<th>Saldo</th>
@@ -84,21 +113,25 @@
<tbody id="monthTable"></tbody>
</table>
</div>
</div>
<details class="details">
<summary>Tagesdetails anzeigen</summary>
<details class="panel details" open>
<summary>
<span class="section-title"><span aria-hidden="true"></span><strong>Tagesdetails</strong></span>
<span class="summary-hint">ein-/ausblenden</span>
</summary>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Datum</th>
<th>Wochentag</th>
<th>Feiertag</th>
<th>Beginn</th>
<th>Ende</th>
<th>Arbeitszeit</th>
<th>Sollzeit</th>
<th>Saldo</th>
<th>Feiertag</th>
<th>Ruhepause</th>
<th>Warnungen</th>
</tr>
@@ -110,6 +143,12 @@
</section>
</main>
<footer class="app-footer">
<span id="appVersion">v0.42.0</span>
<span aria-hidden="true"></span>
<a href="https://git.mike-lindner.net/mike/qplanner-arbeitszeit-auswertung" target="_blank" rel="noopener noreferrer">Repository ↗</a>
</footer>
<script src="app.js"></script>
</body>
</html>
+407 -102
View File
@@ -1,97 +1,243 @@
:root {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #f3f6fb;
--bg: #f5f8fd;
--surface: rgba(255, 255, 255, .92);
--surface-solid: #ffffff;
--text: #0f1f3d;
--muted: #5c6b84;
--line: #dbe5f2;
--blue: #2563eb;
--blue-soft: #eaf2ff;
--green: #119b6b;
--green-soft: #e7f8f1;
--orange: #f28c18;
--orange-soft: #fff3df;
--red: #dc2626;
--red-soft: #fff1f2;
--shadow: 0 18px 48px rgba(29, 52, 92, .10);
--shadow-soft: 0 10px 26px rgba(29, 52, 92, .07);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: var(--bg);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at top left, rgba(37, 99, 235, .10), transparent 34rem),
radial-gradient(circle at 72% 12%, rgba(17, 155, 107, .09), transparent 24rem),
linear-gradient(180deg, #fbfdff 0%, var(--bg) 55%, #f8fbff 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(37, 99, 235, .035) 1px, transparent 1px),
linear-gradient(90deg, rgba(37, 99, 235, .035) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
.topbar {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(18px);
background: rgba(255, 255, 255, .78);
border-bottom: 1px solid rgba(219, 229, 242, .9);
}
.brand {
width: min(1360px, calc(100% - 32px));
margin: 0 auto;
height: 70px;
display: flex;
align-items: center;
gap: 14px;
}
.brand-icon {
width: 38px;
height: 38px;
display: grid;
place-items: center;
border-radius: 14px;
color: var(--blue);
background: var(--blue-soft);
box-shadow: inset 0 0 0 1px rgba(37, 99, 235, .13);
font-size: 1.35rem;
}
.brand strong {
display: block;
font-size: 1.25rem;
letter-spacing: -.03em;
}
.brand span:last-child {
display: block;
color: var(--muted);
font-size: .9rem;
margin-top: 1px;
}
.app {
width: min(1120px, calc(100% - 32px));
position: relative;
width: min(1360px, calc(100% - 32px));
margin: 0 auto;
padding: 40px 0;
padding: 44px 0 80px;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: end;
margin-bottom: 24px;
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(360px, .75fr);
gap: 32px;
align-items: stretch;
margin-bottom: 22px;
}
.hero-content {
position: relative;
min-height: 300px;
}
.eyebrow {
margin: 0 0 8px;
color: #566277;
font-weight: 700;
letter-spacing: .08em;
margin: 0 0 12px;
color: var(--blue);
font-weight: 800;
letter-spacing: .10em;
text-transform: uppercase;
font-size: .8rem;
font-size: .78rem;
}
h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.2rem);
max-width: 760px;
font-size: clamp(2.35rem, 5vw, 4.2rem);
line-height: .98;
letter-spacing: -.06em;
}
.intro {
max-width: 700px;
color: #566277;
line-height: 1.6;
max-width: 770px;
margin: 20px 0 30px;
color: var(--muted);
line-height: 1.75;
font-size: 1.04rem;
}
.settings {
background: white;
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);
grid-template-columns: repeat(2, minmax(250px, 1fr));
gap: 18px;
max-width: 820px;
}
.settings label {
.setting-card,
.stat-card,
.panel,
.drop-zone {
background: var(--surface);
border: 1px solid rgba(219, 229, 242, .95);
box-shadow: var(--shadow-soft);
backdrop-filter: blur(16px);
}
.setting-card {
min-height: 124px;
border-radius: 22px;
padding: 18px;
display: flex;
align-items: center;
gap: 18px;
}
.setting-icon,
.stat-icon {
flex: 0 0 auto;
width: 56px;
height: 56px;
border-radius: 18px;
display: grid;
place-items: center;
font-size: 1.55rem;
font-weight: 800;
}
.setting-icon.clock,
.stat-days .stat-icon { color: var(--blue); background: var(--blue-soft); }
.setting-icon.location,
.stat-work .stat-icon,
.stat-saldo .stat-icon { color: var(--green); background: var(--green-soft); }
.stat-target .stat-icon { color: var(--orange); background: var(--orange-soft); }
.setting-content {
display: grid;
gap: 8px;
font-size: .9rem;
color: #566277;
width: 100%;
}
.setting-label,
.stat-card > span:not(.stat-icon) {
color: #31415d;
font-size: .92rem;
font-weight: 750;
}
.settings input,
.settings select {
border: 1px solid #cbd5e1;
border-radius: 10px;
padding: 10px 12px;
width: 100%;
border: 1px solid #cfd9e8;
border-radius: 14px;
padding: 12px 14px;
font: inherit;
color: #172033;
color: var(--text);
background: #fff;
outline: none;
box-shadow: inset 0 1px 0 rgba(15, 31, 61, .03);
transition: border-color .18s ease, box-shadow .18s ease;
}
.settings small {
color: #566277;
line-height: 1.4;
.settings input:focus,
.settings select:focus {
border-color: rgba(37, 99, 235, .6);
box-shadow: 0 0 0 4px rgba(37, 99, 235, .10);
}
.drop-zone {
position: relative;
display: grid;
place-items: center;
min-height: 190px;
border: 2px dashed #9aa8bd;
border-radius: 24px;
background: white;
min-height: 302px;
border: 2px dashed rgba(37, 99, 235, .48);
border-radius: 28px;
cursor: pointer;
transition: .2s ease;
overflow: hidden;
transition: transform .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease;
}
.drop-zone::before {
content: "";
position: absolute;
inset: 18px;
border-radius: 22px;
background: radial-gradient(circle, rgba(37, 99, 235, .12), transparent 52%);
opacity: .8;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #2f6fed;
background: #eef4ff;
transform: translateY(-2px);
border-color: var(--blue);
background: rgba(234, 242, 255, .84);
box-shadow: var(--shadow);
}
.drop-zone input {
@@ -99,60 +245,131 @@ h1 {
inset: 0;
opacity: 0;
cursor: pointer;
z-index: 3;
}
.drop-content {
position: relative;
z-index: 2;
display: grid;
justify-items: center;
gap: 8px;
text-align: center;
color: #566277;
color: var(--muted);
}
.upload-icon {
width: 84px;
height: 84px;
display: grid;
place-items: center;
border-radius: 28px;
color: var(--blue);
background: #fff;
box-shadow: var(--shadow-soft);
font-size: 2.8rem;
line-height: 1;
}
.drop-content strong {
color: #172033;
font-size: 1.25rem;
color: var(--text);
font-size: 1.15rem;
margin-top: 8px;
}
.drop-content em {
margin-top: 8px;
font-style: normal;
font-size: .88rem;
color: var(--muted);
}
.error {
margin: 18px 0 0;
padding: 16px 18px;
border-radius: 18px;
background: var(--red-soft);
color: #be123c;
border: 1px solid #fecdd3;
font-weight: 650;
}
.hidden { display: none !important; }
.format-hint {
min-height: 26px;
margin: 14px 0 0;
color: var(--muted);
font-size: .94rem;
}
.summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
gap: 18px;
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);
.stat-card {
position: relative;
min-height: 146px;
border-radius: 22px;
padding: 22px;
display: grid;
grid-template-columns: 64px 1fr;
grid-template-areas:
"icon label"
"icon value"
"icon hint";
align-items: center;
column-gap: 16px;
overflow: hidden;
}
.card span {
display: block;
color: #566277;
font-size: .9rem;
margin-bottom: 8px;
.stat-card::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 3px;
background: var(--blue);
}
.card strong {
display: block;
font-size: 1.8rem;
.stat-work::after,
.stat-saldo::after { background: var(--green); }
.stat-target::after { background: var(--orange); }
.stat-icon { grid-area: icon; }
.stat-card > span:not(.stat-icon) { grid-area: label; }
.stat-card strong {
grid-area: value;
font-size: clamp(1.7rem, 3vw, 2.25rem);
letter-spacing: -.04em;
}
.stat-card small {
grid-area: hint;
color: var(--muted);
}
.positive { color: #137333; }
.negative { color: #b3261e; }
.neutral { color: #172033; }
.stat-saldo strong.positive { color: var(--green); }
.stat-saldo strong.negative { color: var(--red); }
.results {
background: white;
border: 1px solid #dde5f2;
border-radius: 24px;
padding: 24px;
box-shadow: 0 12px 30px rgba(29, 45, 68, .08);
display: grid;
grid-template-columns: minmax(0, .95fr) minmax(0, 1.05fr);
gap: 18px;
align-items: start;
}
.table-header {
.panel {
border-radius: 24px;
padding: 22px;
overflow: hidden;
}
.table-header,
.details summary {
display: flex;
justify-content: space-between;
align-items: center;
@@ -160,74 +377,162 @@ h1 {
margin-bottom: 16px;
}
.table-header h2 { margin: 0; }
.section-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.section-title > span {
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 10px;
background: var(--blue-soft);
color: var(--blue);
font-weight: 900;
}
.table-header h2,
.details summary strong {
margin: 0;
font-size: 1.2rem;
letter-spacing: -.02em;
}
button {
border: 0;
border-radius: 12px;
background: #172033;
color: white;
border: 1px solid rgba(37, 99, 235, .18);
border-radius: 14px;
padding: 10px 14px;
background: #fff;
color: var(--blue);
font: inherit;
font-weight: 750;
cursor: pointer;
box-shadow: var(--shadow-soft);
transition: transform .16s ease, box-shadow .16s ease, background .16s ease;
}
button:hover {
transform: translateY(-1px);
background: var(--blue-soft);
}
.table-wrap {
overflow-x: auto;
margin: 0 -22px -22px;
}
table {
width: 100%;
border-collapse: collapse;
min-width: 680px;
font-size: .93rem;
}
th, td {
padding: 12px 10px;
border-bottom: 1px solid #e6edf7;
th,
td {
padding: 14px 22px;
border-bottom: 1px solid #e8eef6;
text-align: left;
white-space: nowrap;
}
th {
color: #566277;
font-size: .85rem;
text-transform: uppercase;
letter-spacing: .04em;
color: #3c4a63;
font-size: .82rem;
font-weight: 850;
background: #f8fbff;
}
tbody tr {
transition: background .15s ease;
}
tbody tr:hover td {
background: rgba(234, 242, 255, .55);
}
tr.holiday-row td {
background: #fff7ed;
}
tr.weekend-row td {
background: #f8fafc;
}
.positive { color: var(--green); font-weight: 850; }
.negative { color: var(--red); font-weight: 850; }
.neutral { color: var(--muted); font-weight: 850; }
.details {
margin-top: 24px;
margin: 0;
}
.details summary {
cursor: pointer;
font-weight: 700;
margin-bottom: 16px;
list-style: none;
}
.error {
margin-top: 16px;
padding: 14px 16px;
border-radius: 14px;
background: #fff1f0;
border: 1px solid #ffccc7;
color: #8a1f11;
.details summary::-webkit-details-marker { display: none; }
.summary-hint {
color: var(--muted);
border: 1px solid var(--line);
border-radius: 12px;
padding: 8px 10px;
font-size: .86rem;
background: #fff;
}
.hidden { display: none; }
.app-footer {
position: fixed;
right: 18px;
bottom: 12px;
z-index: 30;
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 999px;
color: var(--muted);
background: rgba(255, 255, 255, .78);
border: 1px solid rgba(219, 229, 242, .9);
backdrop-filter: blur(14px);
box-shadow: 0 8px 22px rgba(29, 52, 92, .08);
font-size: .82rem;
}
.app-footer a {
color: var(--blue);
text-decoration: none;
font-weight: 750;
}
.app-footer a:hover { text-decoration: underline; }
@media (max-width: 1100px) {
.hero,
.results { grid-template-columns: 1fr; }
.drop-zone { min-height: 240px; }
}
@media (max-width: 820px) {
.hero { display: grid; align-items: start; }
.app { padding-top: 28px; }
.settings,
.summary { grid-template-columns: 1fr 1fr; }
.brand { height: 62px; }
.hero { gap: 22px; }
}
@media (max-width: 520px) {
@media (max-width: 620px) {
.app,
.brand { width: min(100% - 22px, 1360px); }
.settings,
.summary { grid-template-columns: 1fr; }
}
.hint {
color: #566277;
margin: 16px 0 0;
font-size: .95rem;
.setting-card,
.stat-card { border-radius: 18px; }
.table-header,
.details summary { align-items: flex-start; flex-direction: column; }
.app-footer { left: 12px; right: 12px; justify-content: center; }
}