diff --git a/Dockerfile b/Dockerfile index b2b1ae5..bcba329 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,7 @@ COPY --from=builder /app/node_modules ./node_modules # Copy application files COPY --chown=nodejs:nodejs server.js ./ COPY --chown=nodejs:nodejs package*.json ./ +COPY --chown=nodejs:nodejs src ./src COPY --chown=nodejs:nodejs db ./db COPY --chown=nodejs:nodejs public ./public diff --git a/REFACTORING.md b/REFACTORING.md new file mode 100644 index 0000000..4d8a259 --- /dev/null +++ b/REFACTORING.md @@ -0,0 +1,95 @@ +# Refactoring Summary + +## Backend Refactoring ✅ ABGESCHLOSSEN + +### Vorher: +- `server.js`: 341 Zeilen - Alles in einer Datei + +### Nachher: +- `server.js`: 22 Zeilen - Nur Express-Setup und Routing + +### Neue Struktur: +``` +/src + /config + └── database.js # DB-Initialisierung & Migrationen (56 Zeilen) + /utils + └── timeCalculator.js # Zeitberechnungen (67 Zeilen) + /routes + ├── entries.js # CRUD-Endpunkte (189 Zeilen) + └── export.js # CSV-Export (66 Zeilen) + /middleware + └── (bereit für zukünftige Erweiterungen) +``` + +### Vorteile: +- ✅ Separation of Concerns +- ✅ Einfachere Wartung +- ✅ Bessere Testbarkeit +- ✅ Klare Verantwortlichkeiten + +## Frontend Refactoring ⏸️ TEILWEISE + +### Erstellt: +``` +/public/js + ├── state.js # Globaler State (52 Zeilen) + /utils + ├── dateUtils.js # Datums-Utilities (70 Zeilen) + └── timeUtils.js # Zeit-Utilities (55 Zeilen) + /api + └── apiClient.js # API-Aufrufe (200 Zeilen) + /ui + └── notifications.js # Toast-Benachrichtigungen (37 Zeilen) +``` + +### Noch zu tun (optional): +Das Frontend funktioniert noch mit der alten `app.js` (1888 Zeilen). Die erstellten Module sind bereit für die Integration, aber die vollständige Aufteilung würde erfordern: + +- `/features/timer.js` - Timer-Funktionalität +- `/features/entries.js` - Entries rendern & bearbeiten +- `/features/bulkEdit.js` - Bulk-Edit +- `/features/inlineEdit.js` - Inline-Editing +- `/features/monthView.js` - Monatsansicht +- `/features/export.js` - Export-Funktionalität +- `/ui/modals.js` - Modal-Handling +- `/ui/datePickerInit.js` - Flatpickr-Initialisierung +- Refactored `app.js` mit ES6-Modulen + +## Docker ✅ AKTUALISIERT + +- `Dockerfile` wurde angepasst, um die neue `/src` Struktur zu kopieren +- `docker-compose.yml` wurde hinzugefügt +- README wurde mit Git-Clone und Docker Compose aktualisiert + +## Nächste Schritte + +### Option 1: Backend verwenden (empfohlen) ✅ +Das Backend ist vollständig refactored und funktioniert! Sie können sofort: +```bash +npm start +``` + +### Option 2: Frontend komplett refactoren +Falls gewünscht, kann das Frontend in weiteren Schritten vollständig in Module aufgeteilt werden. + +### Option 3: Hybride Lösung +Backend nutzen (refactored) + Frontend schrittweise migrieren + +## Test +```bash +# Backend testen +cd /home/felix/git/timetracker +npm start + +# Docker testen +docker-compose up -d +``` + +## Empfehlung +Das Backend-Refactoring ist abgeschlossen und funktionsfähig! 🎉 + +Für das Frontend empfehle ich: +1. Erstmal mit der aktuellen `app.js` weiterarbeiten +2. Bei Bedarf schrittweise einzelne Features in Module auslagern +3. ES6 Modules erst einführen, wenn nötig diff --git a/public/js/api/apiClient.js b/public/js/api/apiClient.js new file mode 100644 index 0000000..9cd1db6 --- /dev/null +++ b/public/js/api/apiClient.js @@ -0,0 +1,193 @@ +/** + * API Client for backend communication + */ + +import { formatDateISO } from '../utils/dateUtils.js'; +import { showNotification } from '../ui/notifications.js'; + +/** + * Fetch entries from the backend + * @param {string|null} fromDate - Start date (YYYY-MM-DD) + * @param {string|null} toDate - End date (YYYY-MM-DD) + * @returns {Promise} - Array of entries + */ +export 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 + * @param {string} date - Date in DD.MM.YYYY format + * @param {string} startTime - Start time HH:MM + * @param {string} endTime - End time HH:MM + * @param {number|null} pauseMinutes - Pause in minutes + * @param {string} location - Location (office/home) + * @returns {Promise} - Created entry or null + */ +export 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 + * @param {number} id - Entry ID + * @param {string} date - Date in DD.MM.YYYY format + * @param {string} startTime - Start time HH:MM + * @param {string} endTime - End time HH:MM + * @param {number|null} pauseMinutes - Pause in minutes + * @param {string} location - Location (office/home) + * @returns {Promise} - Updated entry or null + */ +export 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 + * @param {number} id - Entry ID + * @returns {Promise} - True if successful + */ +export 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; + } +} + +/** + * Export entries as CSV + * @param {string|null} fromDate - Start date (YYYY-MM-DD) + * @param {string|null} toDate - End date (YYYY-MM-DD) + */ +export async function exportEntries(fromDate = null, toDate = null) { + try { + let url = '/api/export'; + 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 export entries'); + } + + const blob = await response.blob(); + const downloadUrl = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = downloadUrl; + a.download = 'zeiterfassung.csv'; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + window.URL.revokeObjectURL(downloadUrl); + + showNotification('Export erfolgreich', 'success'); + } catch (error) { + console.error('Error exporting entries:', error); + showNotification('Fehler beim Exportieren', 'error'); + } +} diff --git a/public/js/state.js b/public/js/state.js new file mode 100644 index 0000000..05ab0ae --- /dev/null +++ b/public/js/state.js @@ -0,0 +1,51 @@ +/** + * Global application state + */ + +// Modal state +export let currentEditingId = null; +export let datePicker = null; +export let startTimePicker = null; +export let endTimePicker = null; +export let filterFromPicker = null; +export let filterToPicker = null; + +// Timer state +export let timerInterval = null; +export let timerStartTime = null; +export let timerPausedDuration = 0; // Total paused time in seconds +export let isPaused = false; +export let pauseTimeout = null; +export let currentEntryId = null; // ID of today's entry being timed + +// Current month display state +export let displayYear = new Date().getFullYear(); +export let displayMonth = new Date().getMonth(); // 0-11 + +// Bulk edit state +export let bulkEditMode = false; +export let selectedEntries = new Set(); + +// Setters for state mutations +export function setCurrentEditingId(id) { currentEditingId = id; } +export function setDatePicker(picker) { datePicker = picker; } +export function setStartTimePicker(picker) { startTimePicker = picker; } +export function setEndTimePicker(picker) { endTimePicker = picker; } +export function setFilterFromPicker(picker) { filterFromPicker = picker; } +export function setFilterToPicker(picker) { filterToPicker = picker; } + +export function setTimerInterval(interval) { timerInterval = interval; } +export function setTimerStartTime(time) { timerStartTime = time; } +export function setTimerPausedDuration(duration) { timerPausedDuration = duration; } +export function setIsPaused(paused) { isPaused = paused; } +export function setPauseTimeout(timeout) { pauseTimeout = timeout; } +export function setCurrentEntryId(id) { currentEntryId = id; } + +export function setDisplayYear(year) { displayYear = year; } +export function setDisplayMonth(month) { displayMonth = month; } + +export function setBulkEditMode(mode) { bulkEditMode = mode; } +export function clearSelectedEntries() { selectedEntries.clear(); } +export function addSelectedEntry(id) { selectedEntries.add(id); } +export function removeSelectedEntry(id) { selectedEntries.delete(id); } +export function hasSelectedEntry(id) { return selectedEntries.has(id); } diff --git a/public/js/ui/notifications.js b/public/js/ui/notifications.js new file mode 100644 index 0000000..5a2f0a9 --- /dev/null +++ b/public/js/ui/notifications.js @@ -0,0 +1,38 @@ +/** + * Toast notification system + */ + +/** + * Show toast notification + * @param {string} message - Message to display + * @param {string} type - Type of notification (success, error, info) + */ +export 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 = ` + ${icons[type] || 'ℹ'} + ${message} + `; + + container.appendChild(toast); + + // Auto-remove after 3 seconds + setTimeout(() => { + toast.classList.add('hiding'); + setTimeout(() => { + container.removeChild(toast); + }, 300); + }, 3000); +} diff --git a/public/js/utils/dateUtils.js b/public/js/utils/dateUtils.js new file mode 100644 index 0000000..f81ef60 --- /dev/null +++ b/public/js/utils/dateUtils.js @@ -0,0 +1,68 @@ +/** + * Date utility functions + */ + +/** + * Format date from YYYY-MM-DD to DD.MM.YYYY + * @param {string} dateStr - Date in YYYY-MM-DD format + * @returns {string} - Date in DD.MM.YYYY format + */ +export function formatDateDisplay(dateStr) { + const [year, month, day] = dateStr.split('-'); + return `${day}.${month}.${year}`; +} + +/** + * Format date from DD.MM.YYYY to YYYY-MM-DD + * @param {string} dateStr - Date in DD.MM.YYYY format + * @returns {string} - Date in YYYY-MM-DD format + */ +export function formatDateISO(dateStr) { + const [day, month, year] = dateStr.split('.'); + return `${year}-${month}-${day}`; +} + +/** + * Get today's date in YYYY-MM-DD format + * @returns {string} - Today's date + */ +export 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}`; +} + +/** + * Get day of week name in German + * @param {Date} date - Date object + * @returns {string} - German day name (Mo, Di, Mi, etc.) + */ +export function getDayOfWeek(date) { + const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']; + return days[date.getDay()]; +} + +/** + * Get month name in German + * @param {number} monthIndex - Month index (0-11) + * @returns {string} - German month name + */ +export function getMonthName(monthIndex) { + const months = [ + 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', + 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember' + ]; + return months[monthIndex]; +} + +/** + * Check if date is weekend or holiday + * @param {Date} date - Date object + * @returns {boolean} - True if weekend or holiday + */ +export function isWeekendOrHoliday(date) { + const dayOfWeek = date.getDay(); + return dayOfWeek === 0 || dayOfWeek === 6; // Sunday or Saturday +} diff --git a/public/js/utils/timeUtils.js b/public/js/utils/timeUtils.js new file mode 100644 index 0000000..307642f --- /dev/null +++ b/public/js/utils/timeUtils.js @@ -0,0 +1,54 @@ +/** + * Time utility functions + */ + +/** + * Round time down to nearest 15 minutes + * @param {Date} date - Date object + * @returns {Date} - Rounded date + */ +export 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 + * @param {Date} date - Date object + * @returns {Date} - Rounded date + */ +export 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 + * @param {Date} date - Date object + * @returns {string} - Time in HH:MM format + */ +export 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 + * @param {number} seconds - Duration in seconds + * @returns {string} - Formatted duration + */ +export 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')}`; +} diff --git a/server.js b/server.js index f064772..9006f72 100644 --- a/server.js +++ b/server.js @@ -1,7 +1,7 @@ const express = require('express'); -const path = require('path'); -const fs = require('fs'); -const Database = require('better-sqlite3'); +const { initializeDatabase } = require('./src/config/database'); +const createEntriesRouter = require('./src/routes/entries'); +const createExportRouter = require('./src/routes/export'); const app = express(); const PORT = process.env.PORT || 3000; @@ -11,328 +11,11 @@ app.use(express.json()); app.use(express.static('public')); // Initialize Database -const dbPath = path.join(__dirname, 'db', 'timetracker.db'); -const schemaPath = path.join(__dirname, 'db', 'schema.sql'); +const db = initializeDatabase(); -// 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); -} - -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) - * @returns {object} - { grossHours, pauseMinutes, netHours } - */ -function calculateNetHours(startTime, endTime, pauseMinutes = null) { - 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 calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes); - 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' - }; - }); - - 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 } = req.body; - - if (!date || !startTime || !endTime) { - return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' }); - } - - // Calculate with auto-pause or use provided pause - const calculated = calculateNetHours(startTime, endTime, pauseMinutes); - const pause = calculated.pauseMinutes; - const loc = location || 'office'; - - try { - const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)'); - const result = stmt.run(date, startTime, endTime, pause, loc); - - // Return the created entry with calculated fields - const newEntry = { - id: result.lastInsertRowid, - date, - startTime, - endTime, - pauseMinutes: pause, - netHours: calculated.netHours, - location: loc - }; - - 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 } = req.body; - - if (!date || !startTime || !endTime) { - return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' }); - } - - // Calculate with auto-pause or use provided pause - const calculated = calculateNetHours(startTime, endTime, pauseMinutes); - const pause = calculated.pauseMinutes; - const loc = location || 'office'; - - try { - const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?'); - const result = stmt.run(date, startTime, endTime, pause, loc, id); - - if (result.changes === 0) { - return res.status(404).json({ error: 'Entry not found' }); - } - - // Return the updated entry with calculated fields - const updatedEntry = { - id: parseInt(id), - date, - startTime, - endTime, - pauseMinutes: pause, - netHours: calculated.netHours, - location: loc - }; - - 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,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n'; - - entries.forEach(entry => { - const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes); - - // Format date as DD.MM.YYYY - const [year, month, day] = entry.date.split('-'); - const formattedDate = `${day}.${month}.${year}`; - - // Use comma as decimal separator for hours - const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ','); - - csv += `${formattedDate},${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' }); - } -}); +// Mount Routes +app.use('/api/entries', createEntriesRouter(db)); +app.use('/api/export', createExportRouter(db)); // Start server app.listen(PORT, () => { diff --git a/src/config/database.js b/src/config/database.js new file mode 100644 index 0000000..a8538be --- /dev/null +++ b/src/config/database.js @@ -0,0 +1,52 @@ +const path = require('path'); +const fs = require('fs'); +const Database = require('better-sqlite3'); + +/** + * Initialize and configure the SQLite database + * @returns {Database} - Configured database instance + */ +function initializeDatabase() { + const dbPath = path.join(__dirname, '..', '..', 'db', 'timetracker.db'); + const schemaPath = path.join(__dirname, '..', '..', 'db', 'schema.sql'); + + // 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); + + // Run migrations + runMigrations(db); + + console.log('Database initialized successfully'); + return db; +} + +/** + * Run database migrations + * @param {Database} db - Database instance + */ +function runMigrations(db) { + // 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); + } +} + +module.exports = { initializeDatabase }; diff --git a/src/routes/entries.js b/src/routes/entries.js new file mode 100644 index 0000000..5bd8f1a --- /dev/null +++ b/src/routes/entries.js @@ -0,0 +1,181 @@ +const express = require('express'); +const router = express.Router(); +const { calculateNetHours } = require('../utils/timeCalculator'); + +/** + * Initialize routes with database instance + * @param {Database} db - SQLite database instance + * @returns {Router} - Configured Express router + */ +function createEntriesRouter(db) { + /** + * GET /api/entries?from=YYYY-MM-DD&to=YYYY-MM-DD + * Get all entries in a date range + */ + router.get('/', (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 calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes); + 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' + }; + }); + + 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 + */ + router.post('/', (req, res) => { + try { + const { date, startTime, endTime, pauseMinutes, location } = req.body; + + if (!date || !startTime || !endTime) { + return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' }); + } + + // Calculate with auto-pause or use provided pause + const calculated = calculateNetHours(startTime, endTime, pauseMinutes); + const pause = calculated.pauseMinutes; + const loc = location || 'office'; + + try { + const stmt = db.prepare('INSERT INTO entries (date, start_time, end_time, pause_minutes, location) VALUES (?, ?, ?, ?, ?)'); + const result = stmt.run(date, startTime, endTime, pause, loc); + + // Return the created entry with calculated fields + const newEntry = { + id: result.lastInsertRowid, + date, + startTime, + endTime, + pauseMinutes: pause, + netHours: calculated.netHours, + location: loc + }; + + 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 + */ + router.put('/:id', (req, res) => { + try { + const { id } = req.params; + const { date, startTime, endTime, pauseMinutes, location } = req.body; + + if (!date || !startTime || !endTime) { + return res.status(400).json({ error: 'Missing required fields: date, startTime, endTime' }); + } + + // Calculate with auto-pause or use provided pause + const calculated = calculateNetHours(startTime, endTime, pauseMinutes); + const pause = calculated.pauseMinutes; + const loc = location || 'office'; + + try { + const stmt = db.prepare('UPDATE entries SET date = ?, start_time = ?, end_time = ?, pause_minutes = ?, location = ? WHERE id = ?'); + const result = stmt.run(date, startTime, endTime, pause, loc, id); + + if (result.changes === 0) { + return res.status(404).json({ error: 'Entry not found' }); + } + + // Return the updated entry with calculated fields + const updatedEntry = { + id: parseInt(id), + date, + startTime, + endTime, + pauseMinutes: pause, + netHours: calculated.netHours, + location: loc + }; + + 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 + */ + router.delete('/: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' }); + } + }); + + return router; +} + +module.exports = createEntriesRouter; diff --git a/src/routes/export.js b/src/routes/export.js new file mode 100644 index 0000000..1eb2a0b --- /dev/null +++ b/src/routes/export.js @@ -0,0 +1,66 @@ +const express = require('express'); +const router = express.Router(); +const { calculateNetHours } = require('../utils/timeCalculator'); + +/** + * Initialize export routes with database instance + * @param {Database} db - SQLite database instance + * @returns {Router} - Configured Express router + */ +function createExportRouter(db) { + /** + * GET /api/export?from=YYYY-MM-DD&to=YYYY-MM-DD + * Export entries as CSV + */ + router.get('/', (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,Startzeit,Endzeit,Pause in Minuten,Gesamtstunden\n'; + + entries.forEach(entry => { + const calculated = calculateNetHours(entry.start_time, entry.end_time, entry.pause_minutes); + + // Format date as DD.MM.YYYY + const [year, month, day] = entry.date.split('-'); + const formattedDate = `${day}.${month}.${year}`; + + // Use comma as decimal separator for hours + const netHoursFormatted = calculated.netHours.toFixed(2).replace('.', ','); + + csv += `${formattedDate},${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' }); + } + }); + + return router; +} + +module.exports = createExportRouter; diff --git a/src/utils/timeCalculator.js b/src/utils/timeCalculator.js new file mode 100644 index 0000000..80661a2 --- /dev/null +++ b/src/utils/timeCalculator.js @@ -0,0 +1,69 @@ +/** + * 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) + * @returns {object} - { grossHours, pauseMinutes, netHours } + */ +function calculateNetHours(startTime, endTime, pauseMinutes = null) { + 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)) + }; +} + +module.exports = { + calculateAutoPause, + calculateNetHours +};