const express = require('express'); const path = require('path'); const fs = require('fs'); const Database = require('better-sqlite3'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.json()); app.use(express.static('public')); // Initialize Database const dbPath = path.join(__dirname, 'db', 'timetracker.db'); const schemaPath = path.join(__dirname, 'schema.sql'); // Schema one level up // Ensure db directory exists const dbDir = path.dirname(dbPath); if (!fs.existsSync(dbDir)) { fs.mkdirSync(dbDir, { recursive: true }); } const db = new Database(dbPath); // Create table if it doesn't exist const schema = fs.readFileSync(schemaPath, 'utf8'); db.exec(schema); // Migration: Add location column if it doesn't exist try { const tableInfo = db.pragma('table_info(entries)'); const hasLocationColumn = tableInfo.some(col => col.name === 'location'); if (!hasLocationColumn) { console.log('Adding location column to entries table...'); db.exec(`ALTER TABLE entries ADD COLUMN location TEXT DEFAULT 'office' CHECK(location IN ('office', 'home'))`); console.log('Location column added successfully'); } } catch (error) { console.error('Error during migration:', error); } // Migration: Add entry_type column if it doesn't exist try { const tableInfo = db.pragma('table_info(entries)'); const hasEntryTypeColumn = tableInfo.some(col => col.name === 'entry_type'); if (!hasEntryTypeColumn) { console.log('Adding entry_type column to entries table...'); db.exec(`ALTER TABLE entries ADD COLUMN entry_type TEXT DEFAULT 'work' CHECK(entry_type IN ('work', 'vacation', 'flextime'))`); console.log('Entry_type column added successfully'); } } catch (error) { console.error('Error during entry_type migration:', error); } // Migration: Make start_time and end_time nullable for vacation/flextime entries try { // SQLite doesn't support ALTER COLUMN directly, so we check if we can insert NULL values // If the column is already nullable, this will work; if not, we'd need to recreate the table // For simplicity, we'll handle this in the application logic console.log('Time columns migration check completed'); } catch (error) { console.error('Error during time columns 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'); // Initialize default settings if they don't exist const initSetting = db.prepare(` INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?) `); initSetting.run('bundesland', 'BW'); initSetting.run('vacationDays', '30'); console.log('Default settings initialized'); } catch (error) { console.error('Error creating settings table:', error); } console.log('Database initialized successfully'); // ============================================ // BUSINESS LOGIC: Net Hours Calculation & Caps // ============================================ /** * Auto-calculate pause based on German break rules * @param {number} grossHours - Total work hours * @returns {number} - Pause in minutes */ function calculateAutoPause(grossHours) { if (grossHours > 9) { return 45; } else if (grossHours > 6) { return 30; } return 0; } /** * Calculate net hours with pause and 10-hour cap * @param {string} startTime - Format: "HH:MM" * @param {string} endTime - Format: "HH:MM" * @param {number|null} pauseMinutes - Manual pause in minutes (null for auto-calculation) * @param {string} entryType - Type of entry: 'work', 'vacation', 'flextime' * @returns {object} - { grossHours, pauseMinutes, netHours } */ function calculateNetHours(startTime, endTime, pauseMinutes = null, entryType = 'work') { // Special handling for non-work entries if (entryType === 'vacation') { return { grossHours: 0, pauseMinutes: 0, netHours: 0 }; } if (entryType === 'flextime') { return { grossHours: 0, pauseMinutes: 0, netHours: 0 }; } // Regular work entry calculation const [startHour, startMin] = startTime.split(':').map(Number); const [endHour, endMin] = endTime.split(':').map(Number); const startTotalMin = startHour * 60 + startMin; const endTotalMin = endHour * 60 + endMin; // Handle overnight shifts let diffMin = endTotalMin - startTotalMin; if (diffMin < 0) { diffMin += 24 * 60; // Add 24 hours } const grossHours = diffMin / 60; // Calculate required minimum pause based on gross hours const requiredMinPause = calculateAutoPause(grossHours); // Determine actual pause to use let actualPause; if (pauseMinutes !== null && pauseMinutes !== undefined) { // Manual pause provided - enforce minimum actualPause = Math.max(pauseMinutes, requiredMinPause); } else { // No pause provided - use required minimum actualPause = requiredMinPause; } // Calculate net hours const netMinutes = diffMin - actualPause; let netHours = netMinutes / 60; // Cap at 10 hours if (netHours > 10) { netHours = 10.0; } return { grossHours: parseFloat(grossHours.toFixed(2)), pauseMinutes: actualPause, netHours: parseFloat(netHours.toFixed(2)) }; } // ============================================ // API ENDPOINTS // ============================================ /** * GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD * Get all entries in a date range */ app.get('/api/entries', (req, res) => { try { const { from, to } = req.query; let query = 'SELECT * FROM entries'; const params = []; if (from && to) { query += ' WHERE date >= ? AND date <= ?'; params.push(from, to); } else if (from) { query += ' WHERE date >= ?'; params.push(from); } else if (to) { query += ' WHERE date <= ?'; params.push(to); } query += ' ORDER BY date DESC, start_time DESC'; const stmt = db.prepare(query); const entries = stmt.all(...params); // Add calculated net hours to each entry const enrichedEntries = entries.map(entry => { const entryType = entry.entry_type || 'work'; const calculated = calculateNetHours( entry.start_time, entry.end_time, entry.pause_minutes, entryType ); return { id: entry.id, date: entry.date, startTime: entry.start_time, endTime: entry.end_time, pauseMinutes: entry.pause_minutes, netHours: calculated.netHours, location: entry.location || 'office', entryType: entryType }; }); res.json(enrichedEntries); } catch (error) { console.error('Error fetching entries:', error); res.status(500).json({ error: 'Failed to fetch entries' }); } }); /** * POST /api/entries * Create a new entry */ app.post('/api/entries', (req, res) => { try { const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body; const type = entryType || 'work'; // Validate based on entry type if (!date) { return res.status(400).json({ error: 'Missing required field: date' }); } if (type === 'work' && (!startTime || !endTime)) { return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' }); } // Calculate with auto-pause or use provided pause let pause = 0; let start = startTime || '00:00'; let end = endTime || '00:00'; if (type === 'work') { const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type); pause = calculated.pauseMinutes; } const loc = location || 'office'; try { const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location, entry_type) VALUES (?, ?, ?, ?, ?, ?)'); const result = stmt.run(date, start, end, pause, loc, type); // Return the created entry with calculated fields const calculated = calculateNetHours(start, end, pause, type); const newEntry = { id: result.lastInsertRowid, date, startTime: start, endTime: end, pauseMinutes: pause, netHours: calculated.netHours, location: loc, entryType: type }; res.status(201).json(newEntry); } catch (dbError) { // Check for UNIQUE constraint violation if (dbError.message.includes('UNIQUE constraint failed')) { return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' }); } throw dbError; } } catch (error) { console.error('Error creating entry:', error); res.status(500).json({ error: 'Failed to create entry' }); } }); /** * PUT /api/entries/:id * Update an existing entry */ app.put('/api/entries/:id', (req, res) => { try { const { id } = req.params; const { date, startTime, endTime, pauseMinutes, location, entryType } = req.body; const type = entryType || 'work'; if (!date) { return res.status(400).json({ error: 'Missing required field: date' }); } if (type === 'work' && (!startTime || !endTime)) { return res.status(400).json({ error: 'Missing required fields for work entry: startTime, endTime' }); } // Calculate with auto-pause or use provided pause let pause = 0; let start = startTime || '00:00'; let end = endTime || '00:00'; if (type === 'work') { const calculated = calculateNetHours(startTime, endTime, pauseMinutes, type); pause = calculated.pauseMinutes; } const loc = location || 'office'; try { const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ?, entry_type = ? WHERE id = ?'); const result = stmt.run(date, start, end, pause, loc, type, id); if (result.changes === 0) { return res.status(404).json({ error: 'Entry not found' }); } // Return the updated entry with calculated fields const calculated = calculateNetHours(start, end, pause, type); const updatedEntry = { id: parseInt(id), date, startTime: start, endTime: end, pauseMinutes: pause, netHours: calculated.netHours, location: loc, entryType: type }; res.json(updatedEntry); } catch (dbError) { // Check for UNIQUE constraint violation if (dbError.message.includes('UNIQUE constraint failed')) { return res.status(409).json({ error: 'Ein Eintrag für dieses Datum existiert bereits' }); } throw dbError; } } catch (error) { console.error('Error updating entry:', error); res.status(500).json({ error: 'Failed to update entry' }); } }); /** * DELETE /api/entries/:id * Delete an entry */ app.delete('/api/entries/:id', (req, res) => { try { const { id } = req.params; const stmt = db.prepare('DELETE FROM entries WHERE id = ?'); const result = stmt.run(id); if (result.changes === 0) { return res.status(404).json({ error: 'Entry not found' }); } res.json({ message: 'Entry deleted successfully' }); } catch (error) { console.error('Error deleting entry:', error); res.status(500).json({ error: 'Failed to delete entry' }); } }); /** * GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD * Export entries as CSV */ app.get('/api/export', (req, res) => { try { const { from, to } = req.query; let query = 'SELECT * FROM entries'; const params = []; if (from && to) { query += ' WHERE date >= ? AND date <= ?'; params.push(from, to); } else if (from) { query += ' WHERE date >= ?'; params.push(from); } else if (to) { query += ' WHERE date <= ?'; params.push(to); } query += ' ORDER BY date ASC, start_time ASC'; const stmt = db.prepare(query); const entries = stmt.all(...params); // Generate CSV with German formatting let csv = 'Datum,Typ,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n'; entries.forEach(entry => { const entryType = entry.entry_type || 'work'; const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes, entryType); // Format date as DD.MM.YYYY const [year, month, day] = entry.date.split('-'); const formattedDate = `${day}.${month}.${year}`; // Type label const typeLabel = entryType === 'vacation' ? 'Urlaub' : entryType === 'flextime' ? 'Gleitzeit' : 'Arbeit'; // Use comma as decimal separator for hours const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ','); csv += `${formattedDate},${typeLabel},${entry.start_time || '-'},${entry.end_time || '-'},${entry.pause_minutes},${netHoursFormatted}\n`; }); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', 'attachment; filename="zeiterfassung.csv"'); res.send(csv); } catch (error) { console.error('Error exporting entries:', error); res.status(500).json({ error: 'Failed to export entries' }); } }); // ============================================ // 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}`); });