feat: add bridge days recommendations feature with display and calculation logic
All checks were successful
Build and Push Docker Image / build (push) Successful in 35s

This commit is contained in:
Felix Schlusche
2025-10-24 19:12:47 +02:00
parent 8d24744c91
commit fb33ea8144
3 changed files with 453 additions and 56 deletions

View File

@@ -1282,6 +1282,7 @@ async function loadMonthlyView() {
renderMonthlyView(entries);
updateMonthDisplay();
updateStatistics(entries);
updateBridgeDaysDisplay();
// Show/hide PDF export button based on whether month is complete
const today = new Date();
@@ -1531,6 +1532,116 @@ async function updateVacationStatistics() {
document.getElementById('statVacationRemaining').textContent = `${vacationRemaining} / ${totalVacationDays}`;
}
/**
* Update bridge days display for current month
*/
function updateBridgeDaysDisplay() {
const container = document.getElementById('bridgeDaysContainer');
const list = document.getElementById('bridgeDaysList');
// Calculate bridge days for current month
const recommendations = calculateBridgeDays(displayYear, displayMonth, currentBundesland);
if (recommendations.length === 0) {
container.classList.add('hidden');
return;
}
// Show container and populate list
container.classList.remove('hidden');
list.innerHTML = '';
recommendations.forEach((rec, index) => {
const startDate = new Date(rec.startDate);
const endDate = new Date(rec.endDate);
const item = document.createElement('div');
item.className = 'flex items-start gap-3 p-3 bg-gray-800/50 rounded-lg border border-cyan-600/20 hover:border-cyan-600/40 transition-colors';
// Format date range
const startStr = formatDateDisplay(rec.startDate);
const endStr = formatDateDisplay(rec.endDate);
// Create vacation days list
const vacDaysList = rec.vacationDays.map(d => {
const date = new Date(d);
return `${date.getDate()}.${String(date.getMonth() + 1).padStart(2, '0')}.`;
}).join(', ');
item.innerHTML = `
<div class="flex-shrink-0 w-8 h-8 rounded-full bg-cyan-600/20 flex items-center justify-center text-cyan-400 font-bold text-sm">
${Math.round(rec.ratio)}x
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-200">
${startStr} - ${endStr}
</div>
<div class="text-xs text-gray-400 mt-1">
${rec.vacationDaysNeeded} Urlaubstag${rec.vacationDaysNeeded > 1 ? 'e' : ''} (${vacDaysList}) für ${rec.totalFreeDays} freie Tage
</div>
${rec.holidays.length > 0 ? `
<div class="text-xs text-cyan-400 mt-1">
${rec.holidays.join(', ')}
</div>
` : ''}
</div>
<button class="btn-add-bridge-days flex-shrink-0 px-3 py-1 bg-cyan-600 hover:bg-cyan-700 text-white text-xs rounded transition-colors"
data-days='${JSON.stringify(rec.vacationDays)}'
title="Als Urlaub eintragen">
<i data-lucide="calendar-plus" class="w-3 h-3"></i>
</button>
`;
list.appendChild(item);
});
// Re-initialize icons
if (typeof lucide !== 'undefined' && lucide.createIcons) {
lucide.createIcons();
}
// Add event listeners for quick add buttons
document.querySelectorAll('.btn-add-bridge-days').forEach(btn => {
btn.addEventListener('click', async () => {
const days = JSON.parse(btn.dataset.days);
await addBridgeDaysAsVacation(days);
});
});
}
/**
* Add bridge days as vacation entries
*/
async function addBridgeDaysAsVacation(days) {
let created = 0;
let skipped = 0;
for (const dateStr of days) {
try {
const response = await fetch('/api/entries', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
date: dateStr,
entryType: 'vacation'
})
});
if (response.ok) {
created++;
} else {
skipped++;
}
} catch (error) {
console.error('Error creating vacation entry:', error);
skipped++;
}
}
await reloadView();
showNotification(`${created} Urlaubstag${created > 1 ? 'e' : ''} eingetragen${skipped > 0 ? `, ${skipped} übersprungen` : ''}`, 'success');
}
/**
* Calculate balance from all previous months (starting from first month with entries)
*/
@@ -2696,7 +2807,65 @@ async function handleBundeslandChange(event) {
const newBundesland = event.target.value;
const oldBundesland = currentBundesland;
// Show warning with backup recommendation
// Check for conflicts with existing entries first
const entries = await fetchEntries();
const conflicts = [];
// Get old and new holidays for comparison
const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1];
const oldHolidays = new Set();
const newHolidays = new Set();
years.forEach(year => {
getPublicHolidays(year, oldBundesland).forEach(h => {
// Convert to YYYY-MM-DD format for comparison
const year = h.date.getFullYear();
const month = String(h.date.getMonth() + 1).padStart(2, '0');
const day = String(h.date.getDate()).padStart(2, '0');
oldHolidays.add(`${year}-${month}-${day}`);
});
getPublicHolidays(year, newBundesland).forEach(h => {
// Convert to YYYY-MM-DD format for comparison
const year = h.date.getFullYear();
const month = String(h.date.getMonth() + 1).padStart(2, '0');
const day = String(h.date.getDate()).padStart(2, '0');
const dateKey = `${year}-${month}-${day}`;
newHolidays.add(dateKey);
});
});
// Create a map of new holidays with their names
const newHolidayMap = new Map();
years.forEach(year => {
getPublicHolidays(year, newBundesland).forEach(h => {
const year = h.date.getFullYear();
const month = String(h.date.getMonth() + 1).padStart(2, '0');
const day = String(h.date.getDate()).padStart(2, '0');
const dateKey = `${year}-${month}-${day}`;
newHolidayMap.set(dateKey, h.name);
});
});
// Find dates that are holidays in new state but not in old state, and have entries
entries.forEach(entry => {
if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) {
conflicts.push({
date: entry.date,
displayDate: formatDateDisplay(entry.date),
holidayName: newHolidayMap.get(entry.date)
});
}
});
// If no conflicts, change directly without warning
if (conflicts.length === 0) {
await performBundeslandChange(newBundesland, oldBundesland, event);
return;
}
// Show warning with backup recommendation only if conflicts exist
const bundeslandNames = {
'BW': 'Baden-Württemberg',
'BY': 'Bayern',
@@ -2716,17 +2885,23 @@ async function handleBundeslandChange(event) {
'TH': 'Thüringen'
};
const conflictList = conflicts.map(c => `<li class="text-sm">• ${c.displayDate} (<span class="text-yellow-300">${c.holidayName}</span>)</li>`).join('');
// Create custom modal for bundesland change confirmation
const modalHTML = `
<div id="bundeslandWarningModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50" style="display: flex;">
<div class="bg-gray-800 rounded-xl shadow-2xl p-6 max-w-md w-full mx-4 border border-yellow-600">
<div class="flex items-center gap-3 mb-4">
<i data-lucide="alert-triangle" class="w-8 h-8 text-yellow-500"></i>
<h3 class="text-xl font-bold text-yellow-500">Achtung: Bundesland ändern</h3>
<h3 class="text-xl font-bold text-yellow-500">Achtung: Konflikte gefunden</h3>
</div>
<div class="text-gray-300 mb-6 space-y-3">
<p>Sie möchten das Bundesland von <strong>${bundeslandNames[oldBundesland]}</strong> auf <strong>${bundeslandNames[newBundesland]}</strong> ändern.</p>
<p class="text-yellow-400"><strong>Warnung:</strong> Durch die Änderung der Feiertage können bestehende Einträge betroffen sein. An Tagen, die zu Feiertagen werden, bleiben Arbeitseinträge erhalten.</p>
<p class="text-yellow-400"><strong>Warnung:</strong> Die folgenden Tage werden zu Feiertagen und haben bereits Einträge:</p>
<ul class="bg-gray-900 rounded-lg p-3 max-h-40 overflow-y-auto">
${conflictList}
</ul>
<p class="text-sm text-gray-400">Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.</p>
<div class="bg-gray-900 border border-blue-600 rounded-lg p-3 mt-4">
<p class="text-blue-400 text-sm mb-2"><i data-lucide="info" class="w-4 h-4 inline mr-1"></i> Wir empfehlen ein Backup vor der Änderung:</p>
<button id="quickBackupBtn" class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors text-sm">
@@ -2780,59 +2955,6 @@ async function handleBundeslandChange(event) {
* Perform the actual bundesland change after confirmation
*/
async function performBundeslandChange(newBundesland, oldBundesland, event) {
// Check for conflicts with existing entries
const entries = await fetchEntries();
const conflicts = [];
// Get old and new holidays for comparison
const currentYear = new Date().getFullYear();
const years = [currentYear - 1, currentYear, currentYear + 1];
const oldHolidays = new Set();
const newHolidays = new Set();
years.forEach(year => {
getPublicHolidays(year, oldBundesland).forEach(h => {
oldHolidays.add(h.date.toISOString().split('T')[0]);
});
getPublicHolidays(year, newBundesland).forEach(h => {
newHolidays.add(h.date.toISOString().split('T')[0]);
});
});
// Find dates that are holidays in new state but not in old state, and have entries
entries.forEach(entry => {
if (newHolidays.has(entry.date) && !oldHolidays.has(entry.date)) {
const dateObj = new Date(entry.date);
// Temporarily set to new bundesland to get holiday name
const tempBundesland = currentBundesland;
currentBundesland = newBundesland;
const holidayName = getHolidayName(dateObj);
currentBundesland = tempBundesland;
conflicts.push({
date: entry.date,
displayDate: formatDateDisplay(entry.date),
holidayName: holidayName
});
}
});
// Show conflict info if exists
if (conflicts.length > 0) {
const conflictList = conflicts.map(c => `${c.displayDate} (${c.holidayName})`).join('\n');
const message = `Folgende Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nDie Einträge bleiben erhalten.`;
showNotification(`⚠️ ${conflicts.length} Konflikt(e) gefunden`, 'warning');
console.info(message);
}
// Update state and save
currentBundesland = newBundesland;
await setSetting('bundesland', newBundesland);
// Reload view to show updated holidays
await reloadView();
const bundeslandNames = {
'BW': 'Baden-Württemberg',
'BY': 'Bayern',
@@ -2852,6 +2974,13 @@ async function performBundeslandChange(newBundesland, oldBundesland, event) {
'TH': 'Thüringen'
};
// Update state and save
currentBundesland = newBundesland;
await setSetting('bundesland', newBundesland);
// Reload view to show updated holidays
await reloadView();
showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success');
}
@@ -2915,6 +3044,34 @@ async function handleVacationDaysChange(event) {
return;
}
// Check for conflicts: More vacation days already taken/planned than new value?
const currentYear = new Date().getFullYear();
const fromDate = `${currentYear}-01-01`;
const toDate = `${currentYear}-12-31`;
try {
const allEntries = await fetchEntries(fromDate, toDate);
const vacationCount = allEntries.filter(e => e.entryType === 'vacation').length;
if (vacationCount > newValue) {
// Show warning
const difference = vacationCount - newValue;
const confirmed = confirm(
`⚠️ Warnung: Konflikt erkannt!\n\n` +
`Sie haben bereits ${vacationCount} Urlaubstage für ${currentYear} eingetragen.\n` +
`Der neue Wert von ${newValue} Tag${newValue !== 1 ? 'en' : ''} ist ${difference} Tag${difference > 1 ? 'e' : ''} zu niedrig.\n\n` +
`Möchten Sie den Wert trotzdem auf ${newValue} setzen?`
);
if (!confirmed) {
event.target.value = totalVacationDays;
return;
}
}
} catch (error) {
console.error('Error checking vacation conflicts:', error);
}
totalVacationDays = newValue;
await setSetting('vacationDays', newValue.toString());
await updateVacationStatistics();