Add settings management with Bundesland selection and holiday calculations

This commit is contained in:
Felix Schlusche
2025-10-23 02:43:47 +02:00
parent 720b3d2d03
commit b2823731f1
5 changed files with 419 additions and 30 deletions

100
README.md
View File

@@ -4,14 +4,58 @@ Eine Full-Stack-Zeiterfassungsanwendung, entwickelt mit Node.js, Express, SQLite
## Funktionen
- ✅ Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit)
-Automatische Pausenberechnung nach deutschem Arbeitszeitgesetz
- ✅ Maximum von 10 Stunden Nettoarbeitszeit
- ✅ Filterung nach Zeitraum
- ✅ CSV-Export mit deutscher Formatierung
-Responsive Benutzeroberfläche mit Tailwind CSS
-Moderner Datums-/Zeitauswahl (Flatpickr)
-Docker-Containerisierung
### Zeiterfassung
-**Start/Stop Timer**: Live-Timer mit automatischer Zeiterfassung für den aktuellen Tag
- Pause nach 6 Stunden (30 Min) oder 9 Stunden (45 Min) gemäß deutschem Arbeitszeitgesetz
- Automatische Rundung auf 15-Minuten-Intervalle
- Timer läuft auch nach Seiten-Reload weiter
-**Manuelle Eingabe**: Erfassung von Arbeitszeiten (Datum, Startzeit, Endzeit, Pause)
-**Inline-Bearbeitung**: Schnelle Änderung von Zeiten durch Klick in die Tabelle
-**Standort-Tracking**: Home-Office oder Büro pro Eintrag
### Intelligente Berechnungen
-**Automatische Pausenberechnung** nach deutschem Arbeitszeitgesetz:
- \> 6 Stunden: 30 Minuten Pause
- \> 9 Stunden: 45 Minuten Pause
-**Maximum von 10 Stunden Nettoarbeitszeit** pro Tag
-**Monatliche Statistiken**: Soll-Stunden, Ist-Stunden, Saldo (Monat + Gesamt)
-**Arbeitstage-Berechnung**: Automatische Erkennung von Wochenenden und Feiertagen
### Bundesland-spezifische Feiertage
-**16 Bundesländer**: Auswahl des Bundeslandes für korrekte Feiertagsberechnung
-**Persistente Einstellung**: Bundesland-Auswahl wird gespeichert
-**Kollisionserkennung**: Warnung bei Feiertagen, die mit bestehenden Einträgen kollidieren
-**Alle regionalen Feiertage**: Heilige Drei Könige, Fronleichnam, Reformationstag, etc.
### Monatsansicht & Navigation
-**Monatskalender**: Vollständige Ansicht aller Tage des Monats
-**Farbcodierung**:
- Grün: Home-Office Tage
- Rot: Fehlende Einträge an Arbeitstagen
- Grau: Wochenenden
- Blau: Feiertage mit Namen
-**Vor/Zurück Navigation**: Einfaches Wechseln zwischen Monaten
-**Auto-Fill Funktion**: Automatisches Ausfüllen des gesamten Monats mit Standard-Arbeitszeiten
### Bulk-Operationen
-**Mehrfachauswahl**: Checkbox-Modus für schnelle Massenbearbeitung
-**Bulk-Standort setzen**: Mehrere Einträge auf einmal auf Home/Büro setzen
-**Bulk-Löschen**: Mehrere Einträge auf einmal löschen
### Filter & Export
-**Zeitraum-Filter**: Filterung nach Datum (Von/Bis)
-**CSV-Export (Alle)**: Export aller Einträge im gewählten Zeitraum
-**CSV-Export (Abweichungen)**: Export nur der Tage mit Abweichungen von 8,0 Stunden
- Ideal für Arbeitszeitnachweise bei Gleitzeit
- Zeigt nur relevante Über-/Unterschreitungen
-**Deutsches Format**: Komma als Dezimaltrennzeichen, DD.MM.YYYY Datumsformat
### Benutzerfreundlichkeit
-**Responsive Design**: Optimiert für Desktop, Tablet und Smartphone
-**Dark Mode**: Modernes dunkles Design für augenschonende Arbeit
-**Toast-Benachrichtigungen**: Visuelles Feedback bei Aktionen
-**Flatpickr**: Moderne Datums- und Zeitauswahl mit Touch-Support
-**Persistente Daten**: SQLite-Datenbank mit automatischer Migration
## Technologie-Stack
@@ -61,11 +105,20 @@ Die Anwendung berechnet automatisch die Pausenzeiten gemäß deutschem Arbeitsze
## API-Endpunkte
### Zeiteinträge
- `GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge im Zeitraum abrufen
- `POST /api/entries` - Neuen Eintrag erstellen
- `PUT /api/entries/:id` - Bestehenden Eintrag aktualisieren
- `DELETE /api/entries/:id` - Eintrag löschen
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Einträge als CSV exportieren
### Export
- `GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD` - Alle Einträge als CSV exportieren
- `GET /api/export-deviations?from=YYYY-MM-DD&to=YYYY-MM-DD` - Nur Abweichungen als CSV exportieren
### Einstellungen
- `GET /api/settings/:key` - Einstellung abrufen
- `POST /api/settings` - Einstellung speichern (key, value)
- `GET /api/settings` - Alle Einstellungen abrufen
## Installation & Ausführung
@@ -144,13 +197,32 @@ http://localhost:3000
## CSV-Export-Format
Die exportierte CSV-Datei enthält folgende Spalten:
- **Datum**: Datum im Format TT.MM.JJJJ
- **Startzeit**: Startzeit im Format HH:MM
- **Endzeit**: Endzeit im Format HH:MM
- **Pause in Minuten**: Pausenzeit in Minuten
Die Anwendung bietet zwei Export-Optionen:
### 1. Vollständiger Export (📥 Button)
Exportiert alle Einträge im gewählten Zeitraum (oder alle, wenn kein Filter gesetzt).
### 2. Export nur Abweichungen (⚠️ Button)
Exportiert **nur** Tage, die von der Standard-Arbeitszeit (8,0 Stunden) abweichen.
- **Zweck**: Ideal für Arbeitszeitnachweise bei Gleitzeit-Modellen
- **Inhalt**: Nur Über- und Unterschreitungen der 8-Stunden-Marke
- **Vorteil**: Übersichtlicher Nachweis für HR/Verwaltung ohne irrelevante Standard-Tage
### CSV-Spalten:
- **Datum**: TT.MM.JJJJ (z.B. 23.10.2025)
- **Startzeit**: HH:MM (z.B. 08:00)
- **Endzeit**: HH:MM (z.B. 17:00)
- **Pause in Minuten**: Ganzzahl (z.B. 30)
- **Gesamtstunden**: Nettostunden mit Komma als Dezimaltrennzeichen (z.B. 8,50)
**Beispiel Abweichungs-Export:**
```csv
Datum,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden
21.10.2025,08:00,18:30,45,9,75
22.10.2025,09:00,15:30,30,6,00
```
(Tage mit exakt 8,0h werden nicht exportiert)
## Entwicklung
Die Anwendung verwendet:

View File

@@ -6,3 +6,9 @@ CREATE TABLE IF NOT EXISTS entries (
pause_minutes INTEGER NOT NULL DEFAULT 0,
location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

View File

@@ -24,6 +24,9 @@ let displayMonth = new Date().getMonth(); // 0-11
let bulkEditMode = false;
let selectedEntries = new Set();
// Settings state
let currentBundesland = 'BW'; // Default: Baden-Württemberg
// ============================================
// UTILITY FUNCTIONS
// ============================================
@@ -169,58 +172,103 @@ function getEasterSunday(year) {
}
/**
* Get all public holidays for Baden-Württemberg for a given year
* Get all public holidays for a given year and Bundesland
*/
function getPublicHolidays(year) {
function getPublicHolidays(year, bundesland = currentBundesland) {
const holidays = [];
// Fixed holidays
// Fixed holidays (all states)
holidays.push({ date: new Date(year, 0, 1), name: 'Neujahr' });
holidays.push({ date: new Date(year, 0, 6), name: 'Heilige Drei Könige' });
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, 10, 1), name: 'Allerheiligen' });
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
// 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
// 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
// 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
// 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
// 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 in Baden-Württemberg
* Check if date is a public holiday
* Returns the holiday name or null
*/
function getHolidayName(date) {
const year = date.getFullYear();
const holidays = getPublicHolidays(year);
const holidays = getPublicHolidays(year, currentBundesland);
const dateStr = date.toISOString().split('T')[0];
const holiday = holidays.find(h => {
@@ -610,6 +658,53 @@ async function deleteEntry(id) {
}
}
/**
* 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;
}
}
/**
* Export entries as CSV
*/
@@ -1502,6 +1597,107 @@ async function bulkDeleteEntries() {
showNotification(`${deleted} Eintrag/Einträge gelöscht`, 'success');
}
// ============================================
// BUNDESLAND / SETTINGS
// ============================================
/**
* Handle Bundesland change
*/
async function handleBundeslandChange(event) {
const newBundesland = event.target.value;
const oldBundesland = currentBundesland;
// 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
});
}
});
// Warn user if conflicts exist
if (conflicts.length > 0) {
const conflictList = conflicts.map(c => `${c.displayDate} (${c.holidayName})`).join('\n');
const message = `⚠️ Achtung!\n\nDie folgenden Tage werden zu Feiertagen und haben bereits Einträge:\n\n${conflictList}\n\nMöchten Sie fortfahren? Die Einträge bleiben erhalten, aber die Tage werden als Feiertage markiert.`;
if (!confirm(message)) {
// Revert selection
event.target.value = oldBundesland;
return;
}
}
// Update state and save
currentBundesland = newBundesland;
await setSetting('bundesland', newBundesland);
// Reload view to show updated holidays
await loadMonthlyView();
const bundeslandNames = {
'BW': 'Baden-Württemberg',
'BY': 'Bayern',
'BE': 'Berlin',
'BB': 'Brandenburg',
'HB': 'Bremen',
'HH': 'Hamburg',
'HE': 'Hessen',
'MV': 'Mecklenburg-Vorpommern',
'NI': 'Niedersachsen',
'NW': 'Nordrhein-Westfalen',
'RP': 'Rheinland-Pfalz',
'SL': 'Saarland',
'SN': 'Sachsen',
'ST': 'Sachsen-Anhalt',
'SH': 'Schleswig-Holstein',
'TH': 'Thüringen'
};
showNotification(`✓ Bundesland auf ${bundeslandNames[newBundesland]} gesetzt`, 'success');
}
/**
* Load saved settings
*/
async function loadSettings() {
const savedBundesland = await getSetting('bundesland');
if (savedBundesland) {
currentBundesland = savedBundesland;
document.getElementById('bundeslandSelect').value = savedBundesland;
}
}
// ============================================
// INLINE EDITING
// ============================================
@@ -1862,6 +2058,9 @@ function initializeEventListeners() {
document.getElementById('btnStartWork').addEventListener('click', startWork);
document.getElementById('btnStopWork').addEventListener('click', stopWork);
// Bundesland selection
document.getElementById('bundeslandSelect').addEventListener('change', handleBundeslandChange);
// Close modal when clicking outside
document.getElementById('entryModal').addEventListener('click', (e) => {
if (e.target.id === 'entryModal') {
@@ -1879,9 +2078,10 @@ function initializeEventListeners() {
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener('DOMContentLoaded', async () => {
initializeFlatpickr();
initializeEventListeners();
await loadSettings(); // Load saved settings first
checkRunningTimer(); // Check if timer was running
loadMonthlyView(); // Load monthly view by default
});

View File

@@ -193,6 +193,38 @@
</button>
</div>
</div>
<!-- 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>
<span>Einstellungen</span>
</summary>
<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>
<select id="bundeslandSelect"
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">
<option value="BW">Baden-Württemberg</option>
<option value="BY">Bayern</option>
<option value="BE">Berlin</option>
<option value="BB">Brandenburg</option>
<option value="HB">Bremen</option>
<option value="HH">Hamburg</option>
<option value="HE">Hessen</option>
<option value="MV">Mecklenburg-Vorpommern</option>
<option value="NI">Niedersachsen</option>
<option value="NW">Nordrhein-Westfalen</option>
<option value="RP">Rheinland-Pfalz</option>
<option value="SL">Saarland</option>
<option value="SN">Sachsen</option>
<option value="ST">Sachsen-Anhalt</option>
<option value="SH">Schleswig-Holstein</option>
<option value="TH">Thüringen</option>
</select>
</div>
</div>
</details>
</div>
<!-- Statistics -->

View File

@@ -40,6 +40,20 @@ try {
console.error('Error during migration:', error);
}
// Create settings table if it doesn't exist
try {
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`);
console.log('Settings table ready');
} catch (error) {
console.error('Error creating settings table:', error);
}
console.log('Database initialized successfully');
// ============================================
@@ -334,6 +348,71 @@ app.get('/api/export', (req, res) => {
}
});
// ============================================
// SETTINGS ENDPOINTS
// ============================================
// Get a setting by key
app.get('/api/settings/:key', (req, res) => {
try {
const { key } = req.params;
const stmt = db.prepare('SELECT value FROM settings WHERE key = ?');
const result = stmt.get(key);
if (!result) {
return res.status(404).json({ error: 'Setting not found' });
}
res.json({ key, value: result.value });
} catch (error) {
console.error('Error getting setting:', error);
res.status(500).json({ error: 'Failed to get setting' });
}
});
// Set a setting
app.post('/api/settings', (req, res) => {
try {
const { key, value } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: 'Key and value are required' });
}
const stmt = db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
stmt.run(key, value);
res.json({ key, value });
} catch (error) {
console.error('Error setting setting:', error);
res.status(500).json({ error: 'Failed to set setting' });
}
});
// Get all settings
app.get('/api/settings', (req, res) => {
try {
const stmt = db.prepare('SELECT key, value FROM settings');
const settings = stmt.all();
const result = {};
settings.forEach(s => {
result[s.key] = s.value;
});
res.json(result);
} catch (error) {
console.error('Error getting settings:', error);
res.status(500).json({ error: 'Failed to get settings' });
}
});
// Start server
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);