@@ -878,6 +898,7 @@
+
diff --git a/public/js/bridge-days.js b/public/js/bridge-days.js
new file mode 100644
index 0000000..d0a7d80
--- /dev/null
+++ b/public/js/bridge-days.js
@@ -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;
+}
diff --git a/public/js/main.js b/public/js/main.js
index 5ea91a9..6a4badd 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -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 = `
+
+ ${Math.round(rec.ratio)}x
+
+
+
+ ${startStr} - ${endStr}
+
+
+ ${rec.vacationDaysNeeded} Urlaubstag${rec.vacationDaysNeeded > 1 ? 'e' : ''} (${vacDaysList}) für ${rec.totalFreeDays} freie Tage
+
+ ${rec.holidays.length > 0 ? `
+
+ ${rec.holidays.join(', ')}
+
+ ` : ''}
+
+
+
+
+ `;
+
+ 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 => `
• ${c.displayDate} (${c.holidayName} ) `).join('');
+
// Create custom modal for bundesland change confirmation
const modalHTML = `
-
Achtung: Bundesland ändern
+ Achtung: Konflikte gefunden
Sie möchten das Bundesland von ${bundeslandNames[oldBundesland]} auf ${bundeslandNames[newBundesland]} ändern.
-
Warnung: Durch die Änderung der Feiertage können bestehende Einträge betroffen sein. An Tagen, die zu Feiertagen werden, bleiben Arbeitseinträge erhalten.
+
Warnung: Die folgenden Tage werden zu Feiertagen und haben bereits Einträge:
+
+
Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.
Wir empfehlen ein Backup vor der Änderung:
@@ -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();