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

219
public/js/bridge-days.js Normal file
View File

@@ -0,0 +1,219 @@
/**
* Bridge Days Calculator
* Calculates optimal vacation days based on public holidays
*/
/**
* Calculate bridge days and optimal vacation periods for a month
* @param {number} year - The year to calculate for
* @param {number} month - The month (0-11)
* @param {string} bundesland - The German state code
* @returns {Array} Array of bridge day recommendations
*/
function calculateBridgeDays(year, month, bundesland) {
const recommendations = [];
// Get all holidays for the year
const holidays = getPublicHolidays(year, bundesland);
// Create a calendar map for the entire year
const calendar = createYearCalendar(year, holidays);
// Find all work day blocks (consecutive work days between weekends/holidays)
const workBlocks = findWorkDayBlocks(calendar);
// Evaluate each block and calculate benefit
workBlocks.forEach(block => {
const benefit = evaluateBlock(block, calendar);
if (benefit.ratio >= 2.0) { // Only show if at least 2x benefit
recommendations.push(benefit);
}
});
// Sort by benefit ratio (best deals first)
recommendations.sort((a, b) => b.ratio - a.ratio);
// Filter for the specific month
const monthRecommendations = recommendations.filter(rec => {
const startDate = new Date(rec.startDate);
return startDate.getMonth() === month && startDate.getFullYear() === year;
});
return monthRecommendations;
}
/**
* Create a calendar map for the entire year
* @param {number} year - The year
* @param {Array} holidays - Array of holiday objects
* @returns {Map} Map of date strings to day types
*/
function createYearCalendar(year, holidays) {
const calendar = new Map();
// Create holiday map for fast lookup
const holidayMap = new Map();
holidays.forEach(h => {
const dateStr = formatDateKey(h.date);
holidayMap.set(dateStr, h.name);
});
// Process each day of the year
for (let month = 0; month < 12; month++) {
const daysInMonth = new Date(year, month + 1, 0).getDate();
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(year, month, day);
const dateStr = formatDateKey(date);
const dayOfWeek = date.getDay();
let type;
if (holidayMap.has(dateStr)) {
type = { status: 'HOLIDAY', name: holidayMap.get(dateStr) };
} else if (dayOfWeek === 0 || dayOfWeek === 6) {
type = { status: 'WEEKEND' };
} else {
type = { status: 'WORKDAY' };
}
calendar.set(dateStr, type);
}
}
return calendar;
}
/**
* Format date as YYYY-MM-DD
*/
function formatDateKey(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Find all work day blocks in the calendar
* @param {Map} calendar - The calendar map
* @returns {Array} Array of work day blocks
*/
function findWorkDayBlocks(calendar) {
const blocks = [];
let currentBlock = null;
// Sort dates for sequential processing
const sortedDates = Array.from(calendar.keys()).sort();
sortedDates.forEach(dateStr => {
const dayType = calendar.get(dateStr);
if (dayType.status === 'WORKDAY') {
if (!currentBlock) {
currentBlock = {
startDate: dateStr,
endDate: dateStr,
days: [dateStr]
};
} else {
currentBlock.endDate = dateStr;
currentBlock.days.push(dateStr);
}
} else {
if (currentBlock) {
blocks.push(currentBlock);
currentBlock = null;
}
}
});
// Don't forget the last block
if (currentBlock) {
blocks.push(currentBlock);
}
return blocks;
}
/**
* Evaluate a work day block and calculate benefit
* @param {Object} block - The work day block
* @param {Map} calendar - The calendar map
* @returns {Object} Benefit information
*/
function evaluateBlock(block, calendar) {
const vacationDaysNeeded = block.days.length;
// Find the extended free period (including surrounding weekends/holidays)
let startDate = new Date(block.startDate);
let endDate = new Date(block.endDate);
// Extend backwards to include preceding weekends/holidays
let currentDate = new Date(startDate);
currentDate.setDate(currentDate.getDate() - 1);
while (true) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (!dayType || dayType.status === 'WORKDAY') break;
startDate = new Date(currentDate);
currentDate.setDate(currentDate.getDate() - 1);
}
// Extend forwards to include following weekends/holidays
currentDate = new Date(endDate);
currentDate.setDate(currentDate.getDate() + 1);
while (true) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (!dayType || dayType.status === 'WORKDAY') break;
endDate = new Date(currentDate);
currentDate.setDate(currentDate.getDate() + 1);
}
// Calculate total free days
const totalFreeDays = Math.floor((endDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
// Calculate benefit ratio
const ratio = totalFreeDays / vacationDaysNeeded;
// Find holidays in the period for description
const holidaysInPeriod = [];
currentDate = new Date(startDate);
while (currentDate <= endDate) {
const dateStr = formatDateKey(currentDate);
const dayType = calendar.get(dateStr);
if (dayType && dayType.status === 'HOLIDAY') {
holidaysInPeriod.push(dayType.name);
}
currentDate.setDate(currentDate.getDate() + 1);
}
return {
startDate: formatDateKey(startDate),
endDate: formatDateKey(endDate),
vacationDays: block.days,
vacationDaysNeeded: vacationDaysNeeded,
totalFreeDays: totalFreeDays,
ratio: ratio,
holidays: holidaysInPeriod
};
}
/**
* Get a human-readable description for a bridge day recommendation
* @param {Object} recommendation - The recommendation object
* @returns {string} Description text
*/
function getBridgeDayDescription(recommendation) {
const { vacationDaysNeeded, totalFreeDays, ratio, holidays } = recommendation;
let description = `${vacationDaysNeeded} Urlaubstag${vacationDaysNeeded > 1 ? 'e' : ''} für ${totalFreeDays} freie Tage`;
if (holidays.length > 0) {
description += ` (inkl. ${holidays.join(', ')})`;
}
description += ` - ${ratio.toFixed(1)}x Ertrag`;
return description;
}

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();