Compare commits

...

3 Commits

Author SHA1 Message Date
Felix Schlusche
90666a246c Merge branch 'main' of https://gitea.fx-se.de/maggot/timetracker 2025-10-23 17:53:05 +02:00
Felix Schlusche
e1be63b1ca 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
2025-10-23 17:53:03 +02:00
Felix Schlusche
c20f6d9dff feat: add employee input fields and PDF export buttons
- Added input fields for employee name and ID in settings.
- Introduced a button for exporting the current month as a PDF.
- Added a button for bulk exporting selected entries as a PDF.
- Included jsPDF and jsPDF-AutoTable libraries for PDF generation.
2025-10-23 17:04:55 +02:00
5 changed files with 1497 additions and 59 deletions

View File

@@ -258,6 +258,19 @@
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 */
@keyframes blink {
0%, 100% { opacity: 1; }
@@ -402,51 +415,71 @@
</div>
</div>
<!-- Date Range Filter -->
<div class="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>
<!-- CSV Filter & Export Section (Collapsible) -->
<details class="mt-4 pt-4 border-t border-gray-700">
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
<i data-lucide="chevron-down" class="w-5 h-5 transition-transform details-chevron"></i>
<i data-lucide="filter" class="w-5 h-5 text-blue-400"></i>
<span>CSV Filter & Export</span>
</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>
<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>
<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>
<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 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>
<!-- Settings Section (Collapsible) -->
<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">
<span class="text-lg">⚙️</span>
<summary class="cursor-pointer text-gray-300 hover:text-gray-100 font-medium flex items-center gap-2 select-none">
<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>
</summary>
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<label for="employeeName" class="block text-sm font-medium text-gray-300 mb-1">Mitarbeitername</label>
<input type="text" id="employeeName" placeholder="Max Mustermann"
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">
</div>
<div class="flex-1 min-w-[200px]">
<label for="employeeId" class="block text-sm font-medium text-gray-300 mb-1">Personalnummer</label>
<input type="text" id="employeeId" placeholder="12345"
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">
</div>
</div>
<div class="mt-4 flex flex-wrap gap-4 items-center">
<div class="flex-1 min-w-[200px]">
<label for="bundeslandSelect" class="block text-sm font-medium text-gray-300 mb-1">Bundesland (Feiertage)</label>
@@ -548,8 +581,9 @@
<!-- Month Navigation -->
<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="flex gap-3 flex-wrap">
<div class="grid grid-cols-1 lg:grid-cols-3 items-center gap-4">
<!-- 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"
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>
@@ -562,7 +596,8 @@
</button>
</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"
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>
@@ -576,7 +611,33 @@
</button>
</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>
@@ -623,6 +684,11 @@
<i data-lucide="trash-2" class="w-4 h-4"></i>
<span>Löschen</span>
</button>
<button id="btnBulkExportPDF"
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-all duration-200 text-sm font-medium shadow-sm" title="Ausgewählte als PDF exportieren">
<i data-lucide="file-text" class="w-4 h-4"></i>
<span>PDF Export</span>
</button>
</div>
</div>
</div>
@@ -763,8 +829,16 @@
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/de.js"></script>
<!-- App Logic -->
<script src="app.js"></script>
<!-- jsPDF for PDF export -->
<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>
<!-- App Logic - Load in correct order -->
<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 -->
<script>

180
public/js/api.js Normal file
View 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
View 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);
}

File diff suppressed because it is too large Load Diff

126
public/js/utils.js Normal file
View 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];
}