Add utility functions for date formatting, time rounding, and notifications
- Implemented functions to format dates between YYYY-MM-DD and DD.MM.YYYY - Added functions to get today's date in ISO format - Created functions to round time to the nearest 15 minutes - Developed a function to format time as HH:MM - Added a function to format duration in HH:MM:SS - Implemented a toast notification system with auto-remove functionality - Added functions to get day of the week and month names in German
This commit is contained in:
@@ -258,6 +258,19 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Details/Summary chevron rotation */
|
||||||
|
details[open] .details-chevron {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
details summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
/* Blink animation for running timer icon */
|
/* Blink animation for running timer icon */
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -402,49 +415,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Date Range Filter -->
|
<!-- CSV Filter & Export Section (Collapsible) -->
|
||||||
<div class="flex flex-wrap gap-4 items-end">
|
<details class="mt-4 pt-4 border-t border-gray-700">
|
||||||
<div class="flex-1 min-w-[200px]">
|
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
||||||
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
|
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
||||||
<input type="text" id="filterFrom"
|
<i data-lucide="filter" class="w-5 h-5 text-blue-400"></i>
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
<span>CSV Filter & Export</span>
|
||||||
placeholder="DD.MM.YYYY">
|
</summary>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 items-end">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filterFrom" class="block text-sm font-medium text-gray-300 mb-1">Von</label>
|
||||||
|
<input type="text" id="filterFrom"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="DD.MM.YYYY">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
|
||||||
|
<input type="text" id="filterTo"
|
||||||
|
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="DD.MM.YYYY">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="btnFilter"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
|
||||||
|
<i data-lucide="search" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnClearFilter"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
|
||||||
|
<i data-lucide="x-circle" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnExport"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
|
||||||
|
<i data-lucide="download" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="btnExportDeviations"
|
||||||
|
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
|
||||||
|
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</details>
|
||||||
<div class="flex-1 min-w-[200px]">
|
|
||||||
<label for="filterTo" class="block text-sm font-medium text-gray-300 mb-1">Bis</label>
|
|
||||||
<input type="text" id="filterTo"
|
|
||||||
class="w-full px-4 py-2 border border-gray-600 bg-gray-700 text-gray-100 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
placeholder="DD.MM.YYYY">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<button id="btnFilter"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200 shadow-sm" title="Filtern">
|
|
||||||
<i data-lucide="search" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnClearFilter"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-gray-700 text-gray-100 rounded-lg hover:bg-gray-600 transition-all duration-200 shadow-sm" title="Filter zurücksetzen">
|
|
||||||
<i data-lucide="x-circle" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnExport"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-all duration-200 shadow-sm" title="Export (alle)">
|
|
||||||
<i data-lucide="download" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button id="btnExportDeviations"
|
|
||||||
class="inline-flex items-center justify-center w-10 h-10 bg-amber-600 text-white rounded-lg hover:bg-amber-700 transition-all duration-200 shadow-sm" title="Export (nur Abweichungen)">
|
|
||||||
<i data-lucide="alert-circle" class="w-5 h-5"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Section (Collapsible) -->
|
<!-- Settings Section (Collapsible) -->
|
||||||
<details class="mt-4 pt-4 border-t border-gray-700">
|
<details class="mt-4 pt-4 border-t border-gray-700">
|
||||||
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium text-sm flex items-center gap-2">
|
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
|
||||||
<span class="text-lg">⚙️</span>
|
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
|
||||||
|
<i data-lucide="settings" class="w-5 h-5 text-purple-400"></i>
|
||||||
<span>Einstellungen</span>
|
<span>Einstellungen</span>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
<div class="mt-4 flex flex-wrap gap-4 items-center">
|
||||||
@@ -560,8 +581,9 @@
|
|||||||
|
|
||||||
<!-- Month Navigation -->
|
<!-- Month Navigation -->
|
||||||
<div class="mb-8 glass-card rounded-xl p-5 border border-gray-700/50">
|
<div class="mb-8 glass-card rounded-xl p-5 border border-gray-700/50">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-3 items-center gap-4">
|
||||||
<div class="flex gap-3 flex-wrap">
|
<!-- Left: Action Buttons (hidden on mobile, shown on desktop at start) -->
|
||||||
|
<div class="hidden lg:flex gap-3 flex-wrap justify-start order-2 lg:order-1">
|
||||||
<button id="btnToggleBulkEdit"
|
<button id="btnToggleBulkEdit"
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
||||||
<i data-lucide="check-square" class="w-5 h-5"></i>
|
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||||
@@ -572,14 +594,10 @@
|
|||||||
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
||||||
<span>Ausfüllen</span>
|
<span>Ausfüllen</span>
|
||||||
</button>
|
</button>
|
||||||
<button id="btnExportPDF"
|
|
||||||
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
|
|
||||||
<i data-lucide="file-text" class="w-5 h-5"></i>
|
|
||||||
<span>PDF Export</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="monthNavigation" class="flex items-center gap-4">
|
<!-- Center: Month Navigation (always visible and centered) -->
|
||||||
|
<div id="monthNavigation" class="flex items-center justify-center gap-4 order-1 lg:order-2">
|
||||||
<button id="btnPrevMonth"
|
<button id="btnPrevMonth"
|
||||||
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-gray-700 to-gray-600 text-gray-100 rounded-xl">
|
class="btn-elevated inline-flex items-center justify-center w-12 h-12 bg-gradient-to-br from-gray-700 to-gray-600 text-gray-100 rounded-xl">
|
||||||
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
<i data-lucide="chevron-left" class="w-6 h-6"></i>
|
||||||
@@ -593,7 +611,33 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-48"></div> <!-- Spacer for alignment -->
|
<!-- Right: Export Button (hidden on mobile, shown on desktop at end) -->
|
||||||
|
<div class="hidden lg:flex justify-end order-3">
|
||||||
|
<button id="btnExportPDF"
|
||||||
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
|
||||||
|
<i data-lucide="file-text" class="w-5 h-5"></i>
|
||||||
|
<span>PDF Export</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Action Buttons (shown only on mobile, below navigation) -->
|
||||||
|
<div class="flex lg:hidden gap-3 flex-wrap justify-center order-3">
|
||||||
|
<button onclick="document.getElementById('btnToggleBulkEdit').click()"
|
||||||
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-gray-700 to-gray-600 text-gray-100 rounded-xl font-semibold" title="Mehrfachauswahl aktivieren">
|
||||||
|
<i data-lucide="check-square" class="w-5 h-5"></i>
|
||||||
|
<span>Auswahl</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('btnAutoFill').click()"
|
||||||
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-indigo-600 to-indigo-500 text-white rounded-xl font-semibold" title="Monat ausfüllen (8h)">
|
||||||
|
<i data-lucide="calendar-check" class="w-5 h-5"></i>
|
||||||
|
<span>Ausfüllen</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="document.getElementById('btnExportPDF').click()"
|
||||||
|
class="btn-elevated inline-flex items-center gap-2 px-5 py-3 bg-gradient-to-r from-red-600 to-red-500 text-white rounded-xl font-semibold" title="Monat als PDF exportieren">
|
||||||
|
<i data-lucide="file-text" class="w-5 h-5"></i>
|
||||||
|
<span>PDF Export</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -789,8 +833,12 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.8.2/jspdf.plugin.autotable.min.js"></script>
|
||||||
|
|
||||||
<!-- App Logic -->
|
<!-- App Logic - Load in correct order -->
|
||||||
<script src="app.js"></script>
|
<script src="js/state.js"></script>
|
||||||
|
<script src="js/utils.js"></script>
|
||||||
|
<script src="js/holidays.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/main.js"></script>
|
||||||
|
|
||||||
<!-- Initialize Lucide Icons -->
|
<!-- Initialize Lucide Icons -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
180
public/js/api.js
Normal file
180
public/js/api.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* API Functions
|
||||||
|
* Backend communication layer
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch entries from backend
|
||||||
|
*/
|
||||||
|
async function fetchEntries(fromDate = null, toDate = null) {
|
||||||
|
try {
|
||||||
|
let url = '/api/entries';
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (fromDate) params.append('from', fromDate);
|
||||||
|
if (toDate) params.append('to', toDate);
|
||||||
|
|
||||||
|
if (params.toString()) {
|
||||||
|
url += '?' + params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch entries');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await response.json();
|
||||||
|
return entries;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching entries:', error);
|
||||||
|
showNotification('Fehler beim Laden der Einträge', 'error');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new entry
|
||||||
|
*/
|
||||||
|
async function createEntry(date, startTime, endTime, pauseMinutes, location) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
date: formatDateISO(date),
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
location: location || 'office'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include pauseMinutes if explicitly provided (not empty)
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
|
||||||
|
body.pauseMinutes = parseInt(pauseMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to create entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await response.json();
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating entry:', error);
|
||||||
|
showNotification(error.message || 'Fehler beim Erstellen des Eintrags', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing entry
|
||||||
|
*/
|
||||||
|
async function updateEntry(id, date, startTime, endTime, pauseMinutes, location) {
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
date: formatDateISO(date),
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
location: location || 'office'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include pauseMinutes if explicitly provided (not empty)
|
||||||
|
if (pauseMinutes !== null && pauseMinutes !== undefined && pauseMinutes !== '') {
|
||||||
|
body.pauseMinutes = parseInt(pauseMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/entries/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.json();
|
||||||
|
throw new Error(error.error || 'Failed to update entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = await response.json();
|
||||||
|
return entry;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating entry:', error);
|
||||||
|
showNotification(error.message || 'Fehler beim Aktualisieren des Eintrags', 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an entry
|
||||||
|
*/
|
||||||
|
async function deleteEntry(id) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/entries/${id}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete entry');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting entry:', error);
|
||||||
|
showNotification('Fehler beim Löschen des Eintrags', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting by key
|
||||||
|
*/
|
||||||
|
async function getSetting(key) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/settings/${key}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null; // Setting doesn't exist yet
|
||||||
|
}
|
||||||
|
throw new Error('Failed to get setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return data.value;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting setting:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a setting
|
||||||
|
*/
|
||||||
|
async function setSetting(key, value) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ key, value })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to set setting');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting setting:', error);
|
||||||
|
showNotification('Fehler beim Speichern der Einstellung', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
154
public/js/holidays.js
Normal file
154
public/js/holidays.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* Holiday Functions
|
||||||
|
* German public holidays calculation and checking
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is weekend
|
||||||
|
*/
|
||||||
|
function isWeekend(date) {
|
||||||
|
const day = date.getDay();
|
||||||
|
return day === 0 || day === 6; // Sunday or Saturday
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate Easter Sunday for a given year (Gauss algorithm)
|
||||||
|
*/
|
||||||
|
function getEasterSunday(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);
|
||||||
|
const day = ((h + l - 7 * m + 114) % 31) + 1;
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all public holidays for a given year and Bundesland
|
||||||
|
*/
|
||||||
|
function getPublicHolidays(year, bundesland) {
|
||||||
|
const holidays = [];
|
||||||
|
|
||||||
|
// Fixed holidays (all states)
|
||||||
|
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
|
||||||
|
holidays.push({ date: new Date(year, 4, 1), name: 'Tag der Arbeit' });
|
||||||
|
holidays.push({ date: new Date(year, 9, 3), name: 'Tag der Deutschen Einheit' });
|
||||||
|
holidays.push({ date: new Date(year, 11, 25), name: '1. Weihnachtstag' });
|
||||||
|
holidays.push({ date: new Date(year, 11, 26), name: '2. Weihnachtstag' });
|
||||||
|
|
||||||
|
// Heilige Drei Könige (BW, BY, ST)
|
||||||
|
if (['BW', 'BY', 'ST'].includes(bundesland)) {
|
||||||
|
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internationaler Frauentag (BE, MV since 2023)
|
||||||
|
if (['BE'].includes(bundesland) || (bundesland === 'MV' && year >= 2023)) {
|
||||||
|
holidays.push({ date: new Date(year, 2, 8), name: 'Internationaler Frauentag' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weltkindertag (TH since 2019)
|
||||||
|
if (bundesland === 'TH' && year >= 2019) {
|
||||||
|
holidays.push({ date: new Date(year, 8, 20), name: 'Weltkindertag' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reformationstag (BB, MV, SN, ST, TH, + HB, HH, NI, SH since 2018)
|
||||||
|
const reformationstagStates = ['BB', 'MV', 'SN', 'ST', 'TH'];
|
||||||
|
if (year >= 2018) {
|
||||||
|
reformationstagStates.push('HB', 'HH', 'NI', 'SH');
|
||||||
|
}
|
||||||
|
if (reformationstagStates.includes(bundesland)) {
|
||||||
|
holidays.push({ date: new Date(year, 9, 31), name: 'Reformationstag' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allerheiligen (BW, BY, NW, RP, SL)
|
||||||
|
if (['BW', 'BY', 'NW', 'RP', 'SL'].includes(bundesland)) {
|
||||||
|
holidays.push({ date: new Date(year, 10, 1), name: 'Allerheiligen' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buß- und Bettag (only SN)
|
||||||
|
if (bundesland === 'SN') {
|
||||||
|
// Buß- und Bettag is the Wednesday before November 23
|
||||||
|
let bussbettag = new Date(year, 10, 23);
|
||||||
|
while (bussbettag.getDay() !== 3) { // 3 = Wednesday
|
||||||
|
bussbettag.setDate(bussbettag.getDate() - 1);
|
||||||
|
}
|
||||||
|
bussbettag.setDate(bussbettag.getDate() - 7); // One week before
|
||||||
|
holidays.push({ date: bussbettag, name: 'Buß- und Bettag' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Easter-dependent holidays
|
||||||
|
const easter = getEasterSunday(year);
|
||||||
|
|
||||||
|
// Karfreitag (Good Friday) - 2 days before Easter (all states)
|
||||||
|
const goodFriday = new Date(easter);
|
||||||
|
goodFriday.setDate(easter.getDate() - 2);
|
||||||
|
holidays.push({ date: goodFriday, name: 'Karfreitag' });
|
||||||
|
|
||||||
|
// Ostermontag (Easter Monday) - 1 day after Easter (all states)
|
||||||
|
const easterMonday = new Date(easter);
|
||||||
|
easterMonday.setDate(easter.getDate() + 1);
|
||||||
|
holidays.push({ date: easterMonday, name: 'Ostermontag' });
|
||||||
|
|
||||||
|
// Christi Himmelfahrt (Ascension Day) - 39 days after Easter (all states)
|
||||||
|
const ascension = new Date(easter);
|
||||||
|
ascension.setDate(easter.getDate() + 39);
|
||||||
|
holidays.push({ date: ascension, name: 'Christi Himmelfahrt' });
|
||||||
|
|
||||||
|
// Pfingstmontag (Whit Monday) - 50 days after Easter (all states)
|
||||||
|
const whitMonday = new Date(easter);
|
||||||
|
whitMonday.setDate(easter.getDate() + 50);
|
||||||
|
holidays.push({ date: whitMonday, name: 'Pfingstmontag' });
|
||||||
|
|
||||||
|
// Fronleichnam (Corpus Christi) - 60 days after Easter (BW, BY, HE, NW, RP, SL, + some communities in SN, TH)
|
||||||
|
if (['BW', 'BY', 'HE', 'NW', 'RP', 'SL'].includes(bundesland)) {
|
||||||
|
const corpusChristi = new Date(easter);
|
||||||
|
corpusChristi.setDate(easter.getDate() + 60);
|
||||||
|
holidays.push({ date: corpusChristi, name: 'Fronleichnam' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mariä Himmelfahrt (Assumption of Mary) - August 15 (BY in some communities, SL)
|
||||||
|
if (['SL'].includes(bundesland)) {
|
||||||
|
holidays.push({ date: new Date(year, 7, 15), name: 'Mariä Himmelfahrt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is a public holiday
|
||||||
|
* Returns the holiday name or null
|
||||||
|
*/
|
||||||
|
function getHolidayName(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const holidays = getPublicHolidays(year, currentBundesland);
|
||||||
|
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const holiday = holidays.find(h => {
|
||||||
|
return h.date.toISOString().split('T')[0] === dateStr;
|
||||||
|
});
|
||||||
|
|
||||||
|
return holiday ? holiday.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is a public holiday
|
||||||
|
*/
|
||||||
|
function isPublicHoliday(date) {
|
||||||
|
return getHolidayName(date) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if date is weekend or public holiday
|
||||||
|
*/
|
||||||
|
function isWeekendOrHoliday(date) {
|
||||||
|
return isWeekend(date) || isPublicHoliday(date);
|
||||||
|
}
|
||||||
126
public/js/utils.js
Normal file
126
public/js/utils.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* Utility Functions
|
||||||
|
* Date/Time formatting, rounding, duration formatting
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date from YYYY-MM-DD to DD.MM.YYYY
|
||||||
|
*/
|
||||||
|
function formatDateDisplay(dateStr) {
|
||||||
|
const [year, month, day] = dateStr.split('-');
|
||||||
|
return `${day}.${month}.${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format date from DD.MM.YYYY to YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
function formatDateISO(dateStr) {
|
||||||
|
const [day, month, year] = dateStr.split('.');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get today's date in YYYY-MM-DD format
|
||||||
|
*/
|
||||||
|
function getTodayISO() {
|
||||||
|
const today = new Date();
|
||||||
|
const year = today.getFullYear();
|
||||||
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(today.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round time down to nearest 15 minutes
|
||||||
|
*/
|
||||||
|
function roundDownTo15Min(date) {
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
const roundedMinutes = Math.floor(minutes / 15) * 15;
|
||||||
|
date.setMinutes(roundedMinutes);
|
||||||
|
date.setSeconds(0);
|
||||||
|
date.setMilliseconds(0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Round time up to nearest 15 minutes
|
||||||
|
*/
|
||||||
|
function roundUpTo15Min(date) {
|
||||||
|
const minutes = date.getMinutes();
|
||||||
|
const roundedMinutes = Math.ceil(minutes / 15) * 15;
|
||||||
|
date.setMinutes(roundedMinutes);
|
||||||
|
date.setSeconds(0);
|
||||||
|
date.setMilliseconds(0);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time as HH:MM
|
||||||
|
*/
|
||||||
|
function formatTime(date) {
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
return `${hours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format seconds to HH:MM:SS
|
||||||
|
*/
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
const hrs = Math.floor(seconds / 3600);
|
||||||
|
const mins = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${String(hrs).padStart(2, '0')}:${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show toast notification
|
||||||
|
*/
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const container = document.getElementById('toastContainer');
|
||||||
|
|
||||||
|
// Create toast element
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast-${type}`;
|
||||||
|
|
||||||
|
// Icon based on type
|
||||||
|
const icons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
info: 'ℹ'
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.innerHTML = `
|
||||||
|
<span class="toast-icon">${icons[type] || 'ℹ'}</span>
|
||||||
|
<span>${message}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
// Auto-remove after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.add('hiding');
|
||||||
|
setTimeout(() => {
|
||||||
|
container.removeChild(toast);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get day of week abbreviation in German
|
||||||
|
*/
|
||||||
|
function getDayOfWeek(date) {
|
||||||
|
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
|
||||||
|
return days[date.getDay()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get month name in German
|
||||||
|
*/
|
||||||
|
function getMonthName(month) {
|
||||||
|
const months = [
|
||||||
|
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
|
||||||
|
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
|
||||||
|
];
|
||||||
|
return months[month];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user