const worklogRepository = require('../repositories/WorklogRepository'); const userRepository = require('../repositories/UserRepository'); const database = require('../config/database'); /** * Service-Klasse für Zeiteinträge * Enthält die gesamte Business-Logik * Verwendet das Repository-Pattern für Datenbankzugriff */ class TimeEntryService { constructor() { // Default-Benutzer-ID (kann später durch Auth ersetzt werden) this.defaultUserId = 1; } /** * Benutzer-ID setzen (für zukünftige Auth-Integration) * @param {number} userId - ID des aktuellen Benutzers */ setCurrentUser(userId) { this.defaultUserId = userId; } /** * Alle Zeiteinträge abrufen * @param {number} userId - Benutzer-ID (optional) * @returns {Promise} Liste der Zeiteinträge */ async getAllEntries(userId = null) { const uid = userId || this.defaultUserId; const pairs = await worklogRepository.findPairsByUser(uid); const safePairs = Array.isArray(pairs) ? pairs : []; return safePairs.map(pair => this._mapWorklogPairToTimeEntry(pair)); } /** * Einzelnen Zeiteintrag anhand der ID abrufen * @param {number} id - Die ID des Zeiteintrags (start_id) * @returns {Promise} Der gefundene Zeiteintrag oder null */ async getEntryById(id) { const worklog = await worklogRepository.findById(id); if (!worklog) return null; // Wenn es ein Start-Eintrag ist, zugehörigen End-Eintrag suchen if (worklog.relatedTo_id === null) { const pairs = await worklogRepository.findPairsByUser(worklog.user_id); const safePairs = Array.isArray(pairs) ? pairs : []; const pair = safePairs.find(p => p.start_id === parseInt(id)); if (pair) { return this._mapWorklogPairToTimeEntry(pair); } } return null; } /** * Neuen Zeiteintrag erstellen (Timer starten) * @param {Object} entryData - Daten für den neuen Eintrag * @param {string} entryData.description - Beschreibung der Tätigkeit * @param {string} entryData.project - Projektname * @param {number} entryData.userId - Benutzer-ID (optional) * @returns {Promise} Der erstellte Zeiteintrag * @throws {Error} Bei ungültigen Eingabedaten */ async createEntry(entryData) { const userId = entryData.userId || this.defaultUserId; const { description = '', project = 'Allgemein' } = entryData; // Validierung: Prüfen ob bereits ein Timer läuft const runningEntry = await worklogRepository.findRunningByUser(userId); if (runningEntry) { throw new Error('Es läuft bereits ein Timer. Bitte stoppen Sie diesen zuerst.'); } // State-String mit zusätzlichen Informationen const state = JSON.stringify({ action: 'Clock In', project, description }); // Worklog-Eintrag erstellen const worklog = await worklogRepository.create({ user_id: userId, state, tstamp: new Date(), relatedTo_id: null }); // Als TimeEntry-Format zurückgeben return { id: worklog.id, startTime: worklog.tstamp, endTime: null, description, project, duration: null, isRunning: true, userId: worklog.user_id }; } /** * Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern) * @param {number} id - Die ID des zu aktualisierenden Eintrags * @param {Object} updateData - Zu aktualisierende Daten * @param {string} updateData.endTime - Endzeit (ISO 8601) * @param {string} updateData.description - Neue Beschreibung * @param {string} updateData.project - Neuer Projektname * @returns {Promise} Der aktualisierte Zeiteintrag * @throws {Error} Wenn der Eintrag nicht gefunden wurde */ async updateEntry(id, updateData) { const startWorklog = await worklogRepository.findById(id); if (!startWorklog) { throw new Error(`Eintrag mit ID ${id} nicht gefunden`); } // State-Daten extrahieren let stateData; try { stateData = JSON.parse(startWorklog.state); } catch { stateData = { action: 'Clock In', project: 'Allgemein', description: '' }; } const { endTime, description, project } = updateData; // Timer stoppen if (endTime) { const endTimestamp = new Date(endTime); const startTimestamp = new Date(startWorklog.tstamp); // Validierung: Endzeit muss nach Startzeit liegen if (endTimestamp <= startTimestamp) { throw new Error('Endzeit muss nach Startzeit liegen'); } // End-State erstellen const endState = JSON.stringify({ action: 'Clock Out', project: project || stateData.project, description: description !== undefined ? description : stateData.description }); // Clock-Out Worklog erstellen await worklogRepository.create({ user_id: startWorklog.user_id, state: endState, tstamp: endTimestamp, relatedTo_id: id }); // Aktualisiertes Paar abrufen const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id); const safePairs = Array.isArray(pairs) ? pairs : []; const pair = safePairs.find(p => p.start_id === parseInt(id)); return this._mapWorklogPairToTimeEntry(pair); } // Nur Beschreibung/Projekt aktualisieren (ohne Timer zu stoppen) if (description !== undefined || project !== undefined) { const updatedState = JSON.stringify({ ...stateData, project: project !== undefined ? project : stateData.project, description: description !== undefined ? description : stateData.description }); await worklogRepository.update(id, { state: updatedState }); return await this.getEntryById(id); } return await this.getEntryById(id); } /** * Zeiteintrag löschen * @param {number} id - Die ID des zu löschenden Eintrags * @returns {Promise} true wenn erfolgreich gelöscht * @throws {Error} Wenn der Eintrag nicht gefunden wurde */ async deleteEntry(id) { const startWorklog = await worklogRepository.findById(id); if (!startWorklog) { throw new Error(`Eintrag mit ID ${id} nicht gefunden`); } // Zugehörigen End-Eintrag finden und löschen const pairs = await worklogRepository.findPairsByUser(startWorklog.user_id); const pair = pairs.find(p => p.start_id === parseInt(id)); if (pair && pair.end_id) { await worklogRepository.delete(pair.end_id); } // Start-Eintrag löschen return await worklogRepository.delete(id); } /** * Statistiken über alle Zeiteinträge abrufen * @param {number} userId - Benutzer-ID (optional) * @returns {Promise} Statistik-Objekt mit verschiedenen Metriken */ async getStatistics(userId = null) { const uid = userId || this.defaultUserId; // Hole User-Daten (für daily_hours Fallback) const { User } = database.getModels(); const user = await User.findByPk(uid, { attributes: ['daily_hours'], raw: true }); const userDailyHours = user?.daily_hours || 8; let currentlyWorked = null; let allEntries = []; // Hole laufenden Eintrag const runningEntry = await this.getRunningEntry(uid); const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0); const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999); if (runningEntry) { // Berechne ALLE Arbeitszeiten des heutigen Tages // Hole alle Einträge von heute (wird auch für Pausen verwendet) allEntries = await worklogRepository.findByDateRange(uid, todayStart, todayEnd); // Finde alle Start-Work-Paare von heute const workBlocks = []; const startWorks = {}; allEntries.forEach(entry => { let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // ignore } } const action = state?.action || state; if (action === 'start work') { startWorks[entry.id] = { id: entry.id, startTime: entry.tstamp, endTime: null, pauses: [] }; } else if (action === 'stop work' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { startWorks[entry.relatedTo_id].endTime = entry.tstamp; } }); // Sammle Pausen für jeden Block allEntries.forEach(entry => { let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // ignore } } const action = state?.action || state; if (action === 'start pause' && entry.relatedTo_id && startWorks[entry.relatedTo_id]) { startWorks[entry.relatedTo_id].pauses.push({ id: entry.id, startTime: entry.tstamp, endTime: null }); } else if (action === 'stop pause' && entry.relatedTo_id) { // Finde das zugehörige start pause Object.values(startWorks).forEach(block => { const pause = block.pauses.find(p => p.id === entry.relatedTo_id); if (pause) { pause.endTime = entry.tstamp; } }); } }); // Berechne Gesamtarbeitszeit aller Blöcke let totalWorkedMs = 0; Object.values(startWorks).forEach(block => { const blockStart = new Date(block.startTime).getTime(); const blockEnd = block.endTime ? new Date(block.endTime).getTime() : now.getTime(); let blockWorkedMs = blockEnd - blockStart; // Ziehe abgeschlossene Pausen ab block.pauses.forEach(pause => { if (pause.endTime) { const pauseDuration = new Date(pause.endTime).getTime() - new Date(pause.startTime).getTime(); blockWorkedMs -= pauseDuration; } else { // Laufende Pause const pauseDuration = now.getTime() - new Date(pause.startTime).getTime(); blockWorkedMs -= pauseDuration; } }); totalWorkedMs += blockWorkedMs; }); // Formatiere als HH:MM:SS const totalSeconds = Math.floor(totalWorkedMs / 1000); const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = totalSeconds % 60; currentlyWorked = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; } // Berechne "Offen" basierend auf timewish (oder Standard: 8 Stunden) // WICHTIG: Diese Zeit wird OHNE fehlende Pausen berechnet // Das Frontend addiert die fehlenden Pausen dann zur "Normales Arbeitsende" Berechnung let open = null; const { Timewish } = database.getModels(); // now wurde bereits oben deklariert (Zeile 221) const currentDayOfWeek = now.getDay(); // 0=Sonntag, 1=Montag, ..., 6=Samstag // Konvertiere: 0->7 (Sonntag), 1->1 (Montag), ..., 6->6 (Samstag) const timewishDay = currentDayOfWeek === 0 ? 7 : currentDayOfWeek; const timewish = await Timewish.findOne({ where: { user_id: uid, day: timewishDay }, raw: true }); // Berechne "Offen" nur wenn aktuell gearbeitet wird if (currentlyWorked) { if (timewish) { // Timewish vorhanden - verwende Wunschzeit if (timewish.wishtype === 1) { // Ende nach Uhrzeit if (timewish.end_time) { // Parse end_time (HH:MM:SS) const [endHour, endMinute] = timewish.end_time.split(':').map(Number); const endTime = new Date(now.getFullYear(), now.getMonth(), now.getDate(), endHour, endMinute, 0); const remainingMs = endTime.getTime() - now.getTime(); if (remainingMs > 0) { const remainingSeconds = Math.floor(remainingMs / 1000); const h = Math.floor(remainingSeconds / 3600); const m = Math.floor((remainingSeconds % 3600) / 60); const s = remainingSeconds % 60; open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } else { open = '00:00:00'; // Bereits überschritten } } } else if (timewish.wishtype === 2) { // Ende nach Stunden if (timewish.hours) { // Parse currentlyWorked const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number); const workedSeconds = workedH * 3600 + workedM * 60 + workedS; const wishSeconds = Math.floor(timewish.hours * 3600); const remainingSeconds = wishSeconds - workedSeconds; if (remainingSeconds > 0) { const h = Math.floor(remainingSeconds / 3600); const m = Math.floor((remainingSeconds % 3600) / 60); const s = remainingSeconds % 60; open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } else { open = '00:00:00'; // Bereits erreicht } } } } else { // Kein Timewish - Standard: 8 Stunden const [workedH, workedM, workedS] = currentlyWorked.split(':').map(Number); const workedSeconds = workedH * 3600 + workedM * 60 + workedS; const standardSeconds = 8 * 3600; // 8 Stunden const remainingSeconds = standardSeconds - workedSeconds; if (remainingSeconds > 0) { const h = Math.floor(remainingSeconds / 3600); const m = Math.floor((remainingSeconds % 3600) / 60); const s = remainingSeconds % 60; open = `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`; } else { open = '00:00:00'; // Bereits erreicht } } } // Berechne gesetzlich erforderliche Pausen let requiredBreakMinutes = 0; let alreadyTakenBreakMinutes = 0; if (currentlyWorked) { // Geplante Arbeitszeit let plannedWorkHours = 8; // Standard if (timewish && timewish.wishtype === 2 && timewish.hours) { plannedWorkHours = timewish.hours; } // Gesetzliche Pausen: ab 6h -> 30min, ab 9h -> 45min if (plannedWorkHours >= 9) { requiredBreakMinutes = 45; } else if (plannedWorkHours >= 6) { requiredBreakMinutes = 30; } // Berechne ALLE genommenen Pausen von heute (verwende bereits geladene Einträge) if (allEntries.length > 0) { // Sammle alle abgeschlossenen Pausen const pauseStarts = {}; let totalPauseMs = 0; allEntries.forEach(entry => { let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // ignore } } const action = state?.action || state; if (action === 'start pause') { pauseStarts[entry.id] = entry; } else if (action === 'stop pause' && entry.relatedTo_id) { const startPause = pauseStarts[entry.relatedTo_id]; if (startPause) { const pauseDuration = new Date(entry.tstamp).getTime() - new Date(startPause.tstamp).getTime(); totalPauseMs += pauseDuration; delete pauseStarts[entry.relatedTo_id]; } } }); alreadyTakenBreakMinutes = Math.floor(totalPauseMs / (1000 * 60)); } } // Fehlende Pausenzeit const missingBreakMinutes = Math.max(0, requiredBreakMinutes - alreadyTakenBreakMinutes); // Addiere fehlende Pausen zur "Offen" Zeit if (open && open !== '—' && open !== 'Arbeitsende erreicht') { const openParts = open.split(':'); const openH = parseInt(openParts[0]); const openM = parseInt(openParts[1]); const openS = parseInt(openParts[2] || 0); const openMinutes = openH * 60 + openM + missingBreakMinutes; const newOpenH = Math.floor(openMinutes / 60); const newOpenM = openMinutes % 60; const newOpenS = openS; console.log(`🔍 DEBUG Offen-Berechnung: Original=${open}, Fehlende Pausen=${missingBreakMinutes}min, Neu=${newOpenH}:${newOpenM.toString().padStart(2, '0')}:${newOpenS.toString().padStart(2, '0')}`); open = `${newOpenH.toString().padStart(2, '0')}:${newOpenM.toString().padStart(2, '0')}:${newOpenS.toString().padStart(2, '0')}`; } // Berechne Überstunden über den gesamten Zeitraum (alle Wochen) // Neue Berechnung: Timewish-basiert const totalOvertimeResult = await this._calculateTotalOvertime(uid, runningEntry); // Berechne Überstunden für die aktuelle Woche const weekData = await this.getWeekOverview(uid, 0); let weekSollMinutes = 0; // Soll-Arbeitszeit für die Woche let weekIstMinutes = 0; // Ist-Arbeitszeit für die Woche // Hole alle Timewishes für Mo-Fr const allTimewishes = await Timewish.findAll({ where: { user_id: uid, day: { [require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr } }, raw: true }); // Erstelle Map: Tag -> Array von Timewishes const timewishMap = new Map(); allTimewishes.forEach(tw => { if (!timewishMap.has(tw.day)) { timewishMap.set(tw.day, []); } timewishMap.get(tw.day).push(tw); }); // Nur abgeschlossene Tage berücksichtigen (nicht heute, wenn noch gearbeitet wird) const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const includeTodayInOvertime = !runningEntry; // Nur wenn heute schon abgeschlossen ist weekData.days.forEach(day => { // Parse day.date (YYYY-MM-DD) - mit expliziter Zeit um Midnight const dayDate = new Date(day.date + 'T00:00:00'); const dayDateOnly = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate()); const isToday = dayDateOnly.getTime() === todayDate.getTime(); // Zukünftige Tage überspringen if (dayDateOnly > todayDate) { return; } // Heute überspringen, wenn noch gearbeitet wird if (isToday && !includeTodayInOvertime) { return; } const dayOfWeek = dayDate.getDay(); // 0=So, 1=Mo, ..., 6=Sa // Nur Mo-Fr berücksichtigen if (dayOfWeek === 0 || dayOfWeek === 6) { return; // Wochenende überspringen } // Prüfe auf Feiertag, Urlaub, Krankheit if (day.sick) { // Krankheitstag: Soll = 0, Ist wird ignoriert return; } if (day.holiday) { // Feiertag: Soll = 0 return; } if (day.vacation && !day.workBlocks?.length) { // Voller Urlaubstag ohne Arbeit: Soll = 0 return; } // Berechne Soll-Zeit für diesen Tag const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; const dayTimewishes = timewishMap.get(timewishDay) || []; // Finde den passenden Timewish für dieses Datum const applicableTimewish = dayTimewishes.find(tw => { const startDate = new Date(tw.start_date); const endDate = tw.end_date ? new Date(tw.end_date) : null; // Prüfe ob dayDate im Gültigkeitsbereich liegt if (dayDate < startDate) return false; if (endDate && dayDate > endDate) return false; return true; }); let daySollHours = userDailyHours; // Fallback: User's daily_hours if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { daySollHours = applicableTimewish.hours; } // Bei halbem Urlaubstag: Soll halbiert if (day.vacation && day.vacation.halfDay) { daySollHours = daySollHours / 2; } weekSollMinutes += daySollHours * 60; // Berechne Ist-Zeit für diesen Tag if (day.netWorkTime) { const [h, m] = day.netWorkTime.split(':').map(Number); weekIstMinutes += h * 60 + m; } }); // Überstunden = Ist - Soll const overtimeMinutes = weekIstMinutes - weekSollMinutes; const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); const overtimeMins = Math.abs(overtimeMinutes) % 60; const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; // Wochenarbeitszeit const weekWorktimeHours = Math.floor(weekIstMinutes / 60); const weekWorktimeMins = weekIstMinutes % 60; const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`; // Berechne "Offen für Woche" // Dies ist: Wie lange muss ich HEUTE noch arbeiten, um das Wochenziel zu erreichen? // = Wochensoll (gesamt) - Wochenist (abgeschlossene Tage + heute bisher) let openForWeekFormatted = '—'; if (runningEntry) { // Berechne Wochensoll und -ist let weekSollMinutesTotal = 0; let weekIstMinutesTotal = 0; weekData.days.forEach(day => { // Parse day.date als YYYY-MM-DD und vergleiche nur das Datum (ohne Zeit) const dayDate = new Date(day.date + 'T00:00:00'); const dayDateOnly = new Date(dayDate.getFullYear(), dayDate.getMonth(), dayDate.getDate()); // isToday basierend auf Datumsvergleich (ohne Uhrzeit) const isToday = dayDateOnly.getTime() === todayDate.getTime(); // Zukünftige Tage überspringen if (dayDateOnly > todayDate) { return; } const dayOfWeek = dayDate.getDay(); // Nur Mo-Fr berücksichtigen if (dayOfWeek === 0 || dayOfWeek === 6) { return; } // Prüfe auf Feiertag, Urlaub, Krankheit if (day.sick || day.holiday) { return; } if (day.vacation && !day.workBlocks?.length) { return; } // Berechne Soll-Zeit für diesen Tag const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; const dayTimewishes = timewishMap.get(timewishDay) || []; const applicableTimewish = dayTimewishes.find(tw => { const startDate = new Date(tw.start_date); const endDate = tw.end_date ? new Date(tw.end_date) : null; if (dayDate < startDate) return false; if (endDate && dayDate > endDate) return false; return true; }); let daySollHours = userDailyHours; // Fallback: User's daily_hours if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { daySollHours = applicableTimewish.hours; } if (day.vacation && day.vacation.halfDay) { daySollHours = daySollHours / 2; } weekSollMinutesTotal += daySollHours * 60; // Berechne Ist-Zeit für diesen Tag let dayIstMinutes = 0; if (isToday && currentlyWorked) { // Für heute: Verwende die aktuell gearbeitete Zeit aus currentlyWorked // NICHT day.netWorkTime verwenden, um Doppelzählung zu vermeiden! const [h, m, s] = currentlyWorked.split(':').map(Number); dayIstMinutes = h * 60 + m; weekIstMinutesTotal += dayIstMinutes; } else if (!isToday && day.netWorkTime) { // Für vergangene Tage: Verwende netWorkTime const [h, m] = day.netWorkTime.split(':').map(Number); dayIstMinutes = h * 60 + m; weekIstMinutesTotal += dayIstMinutes; } }); // Offen für Woche = Wochensoll - Wochenist (bisher) const openForWeekMinutes = weekSollMinutesTotal - weekIstMinutesTotal; if (openForWeekMinutes > 0) { // Es muss noch gearbeitet werden, um das Wochenziel zu erreichen const openWeekHours = Math.floor(openForWeekMinutes / 60); const openWeekMins = openForWeekMinutes % 60; openForWeekFormatted = `${openWeekHours}:${openWeekMins.toString().padStart(2, '0')}`; } else if (openForWeekMinutes === 0) { // Exakt das Wochenziel erreicht openForWeekFormatted = 'Wochenziel erreicht'; } else { // Negativ = Wochenziel bereits übertroffen openForWeekFormatted = '—'; } } // Berechne "Bereinigtes Arbeitsende" // Nur berechnen, wenn derzeit gearbeitet wird (NICHT in Pause!) let adjustedEndTodayGeneral = '—'; let adjustedEndTodayWeek = '—'; // Hole den aktuellen Status const currentState = await this.getCurrentState(uid); // Arbeitsende berechnen, wenn derzeit gearbeitet wird // Das ist der Fall bei: 'start work' (direkt nach Arbeitsbeginn) oder 'stop pause' (nach Pausenende) const isCurrentlyWorking = runningEntry && (currentState === 'start work' || currentState === 'stop pause'); if (isCurrentlyWorking && open && open !== '—' && open !== 'Arbeitsende erreicht') { // Parse "Offen" Zeit (Format: HH:MM:SS) const openParts = open.split(':'); const openH = parseInt(openParts[0]); const openM = parseInt(openParts[1]); const openS = parseInt(openParts[2] || 0); const openMinutes = openH * 60 + openM; // Aktuelle Zeit const currentTime = new Date(); // ===== GENERELL ===== // Verbleibende Zeit = Offen - Gesamt-Überstunden + fehlende Pausen const generalOvertimeMinutes = totalOvertimeResult.minutes || 0; const generalRemainingMinutes = openMinutes - generalOvertimeMinutes + missingBreakMinutes; if (generalRemainingMinutes <= 0) { adjustedEndTodayGeneral = 'Arbeitsende erreicht'; } else { // Berechne Uhrzeit: jetzt + verbleibende Minuten const endTime = new Date(currentTime.getTime() + generalRemainingMinutes * 60 * 1000); const endH = endTime.getHours(); const endM = endTime.getMinutes(); adjustedEndTodayGeneral = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`; } // ===== WOCHE ===== // Verbleibende Zeit = Offen - Wochen-Über/Unterstunden + fehlende Pausen const weekOvertimeMinutes = overtimeMinutes; // Bereits berechnet const weekRemainingMinutes = openMinutes - weekOvertimeMinutes + missingBreakMinutes; if (weekRemainingMinutes <= 0) { adjustedEndTodayWeek = 'Arbeitsende erreicht'; } else { // Berechne Uhrzeit: jetzt + verbleibende Minuten const endTime = new Date(currentTime.getTime() + weekRemainingMinutes * 60 * 1000); const endH = endTime.getHours(); const endM = endTime.getMinutes(); adjustedEndTodayWeek = `${endH.toString().padStart(2, '0')}:${endM.toString().padStart(2, '0')}`; } } // Berechne Arbeitsfreie Stunden (Urlaub + Krankheit + Feiertage) bis heute let nonWorkingMinutes = 0; weekData.days.forEach(day => { const dayDate = new Date(day.date); // Nur Tage bis heute berücksichtigen if (dayDate > todayDate) { return; } // Bei laufendem Tag: überspringen if (dayDate.getTime() === todayDate.getTime() && !includeTodayInOvertime) { return; } const dayOfWeek = dayDate.getDay(); // Nur Mo-Fr berücksichtigen if (dayOfWeek === 0 || dayOfWeek === 6) { return; } // Sammle arbeitsfreie Stunden if (day.sick) { nonWorkingMinutes += day.sick.hours * 60; } else if (day.holiday) { nonWorkingMinutes += day.holiday.hours * 60; } else if (day.vacation) { nonWorkingMinutes += day.vacation.hours * 60; } }); const nonWorkingHours = Math.floor(nonWorkingMinutes / 60); const nonWorkingMins = nonWorkingMinutes % 60; const nonWorkingHoursFormatted = nonWorkingMinutes > 0 ? `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}` : null; // TODO: Echte Berechnungen für die anderen Felder implementieren return { timestamp: new Date().toISOString(), // Zeitpunkt der Berechnung currentlyWorked: currentlyWorked, open: open, requiredBreakMinutes: requiredBreakMinutes, // Gesetzlich erforderlich alreadyTakenBreakMinutes: alreadyTakenBreakMinutes, // Bereits genommen missingBreakMinutes: missingBreakMinutes, // Noch fehlend regularEnd: null, overtime: overtime, // Überstunden für die aktuelle Woche totalOvertime: totalOvertimeResult.formatted, // Überstunden über den gesamten Zeitraum (timewish-basiert) weekWorktime: weekWorktime, nonWorkingHours: nonWorkingHoursFormatted, openForWeek: openForWeekFormatted, adjustedEndToday: 'Bereinigtes Arbeitsende (heute)', // Überschrift ohne Zeitwert adjustedEndTodayGeneral: adjustedEndTodayGeneral, adjustedEndTodayWeek: adjustedEndTodayWeek }; } /** * Berechnet die Gesamt-Überstunden über alle Wochen * @param {number} userId - Benutzer-ID * @param {Object|null} runningEntry - Laufender Eintrag (falls vorhanden) * @returns {Promise} { minutes, formatted } * @private */ /** * Berechne Überstunden im alten Style (weekly_worktime) * @private */ async _calculateTotalOvertimeOldStyle(userId, runningEntry) { const { WeeklyWorktime } = database.getModels(); // Finde den ersten Worklog-Eintrag des Users const firstEntry = await worklogRepository.findByUser(userId); if (!firstEntry || firstEntry.length === 0) { return { minutes: 0, formatted: '+0:00' }; } // Hole alle WeeklyWorktime-Einträge für diesen User const weeklyWorktimes = await WeeklyWorktime.findAll({ where: { user_id: userId }, order: [['starting_from', 'ASC']], raw: true }); if (weeklyWorktimes.length === 0) { weeklyWorktimes.push({ weekly_work_time: 40, starting_from: null, ends_at: null }); } // Berechne Soll-Stunden für ein Datum basierend auf weekly_worktime const getDailySollHours = (date) => { const dateObj = new Date(date); const dateString = dateObj.toISOString().split('T')[0]; // Finde passenden weekly_worktime Eintrag let applicable = null; for (const wt of weeklyWorktimes) { const startFrom = wt.starting_from || '1900-01-01'; const endsAt = wt.ends_at || '9999-12-31'; if (dateString >= startFrom && dateString <= endsAt) { applicable = wt; break; } } if (!applicable) { applicable = weeklyWorktimes[weeklyWorktimes.length - 1]; } // Wochenstunden / 5 = Tagesstunden (Mo-Fr) return applicable.weekly_work_time / 5; }; const firstDate = new Date(firstEntry[0].tstamp + 'Z'); const firstMonday = new Date(firstDate); const dayOfWeek = firstDate.getUTCDay(); const daysToMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; firstMonday.setUTCDate(firstDate.getUTCDate() + daysToMonday); firstMonday.setUTCHours(0, 0, 0, 0); const now = new Date(); const todayMonday = new Date(now); const todayDayOfWeek = now.getDay(); const todayDaysToMonday = todayDayOfWeek === 0 ? -6 : 1 - todayDayOfWeek; todayMonday.setDate(now.getDate() + todayDaysToMonday); todayMonday.setHours(0, 0, 0, 0); const weeksDiff = Math.floor((todayMonday - firstMonday) / (7 * 24 * 60 * 60 * 1000)); let totalSollMinutes = 0; let totalIstMinutes = 0; const todayDate = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterdayDate = new Date(todayDate); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const endDate = yesterdayDate; // Iteriere über alle Wochen for (let weekOffset = -weeksDiff; weekOffset <= 0; weekOffset++) { const weekData = await this.getWeekOverview(userId, weekOffset); weekData.days.forEach(day => { const dayDate = new Date(day.date); if (dayDate > endDate) { return; } const dayOfWeek = dayDate.getDay(); if (dayOfWeek === 0 || dayOfWeek === 6) { return; } const daySollHours = getDailySollHours(day.date); if (day.holiday || day.sick) { return; } if (day.vacation) { const vacationHours = day.vacationHours || 0; if (vacationHours === 4) { // Halber Urlaubstag: Soll = Tagesstunden * 0.5 totalSollMinutes += (daySollHours * 0.5) * 60; if (day.netWorkTime) { const [h, m] = day.netWorkTime.split(':').map(Number); totalIstMinutes += h * 60 + m; } } else { // Ganzer Urlaubstag: überspringen return; } } else { // Normaler Arbeitstag // WICHTIG für Alt-Style: Nur Tage zählen, an denen tatsächlich gearbeitet wurde! if (day.netWorkTime) { const [h, m] = day.netWorkTime.split(':').map(Number); const istMinutes = h * 60 + m; totalSollMinutes += daySollHours * 60; totalIstMinutes += istMinutes; } // Tage OHNE Arbeit werden NICHT gezählt (kein Soll, kein Ist) } }); } const overtimeMinutes = totalIstMinutes - totalSollMinutes; const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); const overtimeMins = Math.abs(overtimeMinutes) % 60; const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; return { minutes: overtimeMinutes, formatted }; } async _calculateTotalOvertime(userId, runningEntry) { const { Timewish, Holiday, Vacation, Sick, Worklog, User } = database.getModels(); const sequelize = database.sequelize; // Hole den Überstunden-Offset und daily_hours für diesen User const user = await User.findByPk(userId, { attributes: ['overtime_offset_minutes', 'daily_hours'], raw: true }); const overtimeOffsetMinutes = user?.overtime_offset_minutes || 0; const userDailyHours = user?.daily_hours || 8; // Hole alle Timewishes für Mo-Fr const allTimewishes = await Timewish.findAll({ where: { user_id: userId, day: { [require('sequelize').Op.in]: [1, 2, 3, 4, 5] // Mo-Fr } }, raw: true, order: [['day', 'ASC'], ['start_date', 'ASC']] }); // Erstelle Map: Tag -> Array von Timewishes const timewishMap = new Map(); allTimewishes.forEach(tw => { if (!timewishMap.has(tw.day)) { timewishMap.set(tw.day, []); } timewishMap.get(tw.day).push(tw); }); // Berechne Soll-Stunden für ein Datum basierend auf timewish const getDailySollHours = (dateString, dayOfWeek) => { // Konvertiere JS dayOfWeek (0=So, 1=Mo) zu DB dayOfWeek (1=Mo, 7=So) const timewishDay = dayOfWeek === 0 ? 7 : dayOfWeek; const dayTimewishes = timewishMap.get(timewishDay) || []; // Finde den passenden Timewish für dieses Datum const applicableTimewish = dayTimewishes.find(tw => { const startDate = tw.start_date; const endDate = tw.end_date || '9999-12-31'; return dateString >= startDate && dateString <= endDate; }); if (applicableTimewish && applicableTimewish.wishtype === 2 && applicableTimewish.hours) { return parseFloat(applicableTimewish.hours); } // Fallback: User's daily_hours return parseFloat(userDailyHours); }; // Berechne Endedatum: gestern (wie alte MySQL-Funktion) const now = new Date(); const yesterdayDate = new Date(now); yesterdayDate.setDate(yesterdayDate.getDate() - 1); const endDateStr = yesterdayDate.toISOString().split('T')[0]; // Hole alle Arbeitstage mit Netto-Arbeitszeit aus der DB // WICHTIG: Berücksichtigt timefix-Korrekturen! // Dies ist VIEL schneller als 253x getWeekOverview aufzurufen! const query = ` SELECT DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) as work_date, DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) as day_of_week, SUM( TIMESTAMPDIFF(SECOND, COALESCE(w1_fix.fix_date_time, w1.tstamp), COALESCE(w2_fix.fix_date_time, w2.tstamp) ) - IFNULL(pauses.pause_seconds, 0) ) / 3600 as net_hours FROM worklog w1 LEFT JOIN timefix w1_fix ON w1_fix.worklog_id = w1.id JOIN worklog w2 ON w2.relatedTo_id = w1.id AND w2.state = 'stop work' LEFT JOIN timefix w2_fix ON w2_fix.worklog_id = w2.id LEFT JOIN ( SELECT p1.relatedTo_id as work_id, SUM( TIMESTAMPDIFF(SECOND, COALESCE(p1_fix.fix_date_time, p1.tstamp), COALESCE(p2_fix.fix_date_time, p2.tstamp) ) ) as pause_seconds FROM worklog p1 LEFT JOIN timefix p1_fix ON p1_fix.worklog_id = p1.id JOIN worklog p2 ON p2.relatedTo_id = p1.id AND p2.state = 'stop pause' LEFT JOIN timefix p2_fix ON p2_fix.worklog_id = p2.id WHERE p1.state = 'start pause' GROUP BY p1.relatedTo_id ) pauses ON pauses.work_id = w1.id WHERE w1.state = 'start work' AND w1.user_id = ? AND DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) <= ? AND DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) BETWEEN 2 AND 6 GROUP BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)), DAYOFWEEK(DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp))) ORDER BY DATE(COALESCE(w1_fix.fix_date_time, w1.tstamp)) `; const workDays = await sequelize.query(query, { replacements: [userId, endDateStr], type: sequelize.QueryTypes.SELECT }); // Hole User-Bundesland const { State: StateModel } = database.getModels(); const userForState = await User.findByPk(userId, { attributes: ['state_id'], raw: true }); const userStateId = userForState?.state_id; // Hole Feiertage mit States const holidays = await Holiday.findAll({ where: { date: { [require('sequelize').Op.lte]: endDateStr } }, include: [ { model: StateModel, as: 'states', attributes: ['id'], through: { attributes: [] }, required: false } ] }); // Filtere nur Feiertage die für den User gelten const holidaySet = new Set(); holidays.forEach(h => { const holidayStates = h.states || []; const isFederal = holidayStates.length === 0; const appliesToUser = isFederal || holidayStates.some(s => s.id === userStateId); if (appliesToUser) { holidaySet.add(h.date); } }); // Hole Krankheitstage (expandiert) const sickDaysRaw = await Sick.findAll({ where: { user_id: userId, first_day: { [require('sequelize').Op.lte]: endDateStr } }, raw: true }); const sickSet = new Set(); sickDaysRaw.forEach(sick => { const start = new Date(sick.first_day); const end = new Date(sick.last_day); for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const dow = d.getDay(); if (dow >= 1 && dow <= 5) { // Mo-Fr sickSet.add(d.toISOString().split('T')[0]); } } }); // Hole Urlaubstage (expandiert) const vacationDaysRaw = await Vacation.findAll({ where: { user_id: userId, first_day: { [require('sequelize').Op.lte]: endDateStr } }, raw: true }); const vacationMap = new Map(); // date -> hours (4 oder 8) vacationDaysRaw.forEach(vac => { const start = new Date(vac.first_day); const end = new Date(vac.last_day); const hours = vac.vacation_type === 1 ? 4 : 8; for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) { const dow = d.getDay(); if (dow >= 1 && dow <= 5) { // Mo-Fr vacationMap.set(d.toISOString().split('T')[0], hours); } } }); // Erstelle Map: work_date -> net_hours const workDaysMap = new Map(); let totalIstHoursFromDB = 0; workDays.forEach(wd => { const netHours = parseFloat(wd.net_hours); workDaysMap.set(wd.work_date, netHours); totalIstHoursFromDB += netHours; }); // Berechne Soll-Stunden für alle Arbeitstage let totalSollMinutes = 0; let processedDays = 0; let sickDaysCount = 0; let holidayDaysCount = 0; let vacationFullDays = 0; let vacationHalfDays = 0; let workedDays = 0; let notWorkedDays = 0; // Berechne Soll-Zeit für jeden Tag in workDaysMap (= Tage mit tatsächlicher Arbeit) workDaysMap.forEach((istHours, dateStr) => { const d = new Date(dateStr); const dow = d.getDay(); // Prüfe auf Feiertag, Krankheit, Urlaub if (holidaySet.has(dateStr)) { holidayDaysCount++; return; } if (sickSet.has(dateStr)) { sickDaysCount++; return; } const vacationHours = vacationMap.get(dateStr); if (vacationHours) { if (vacationHours === 4) { // Halber Urlaubstag vacationHalfDays++; const sollHours = getDailySollHours(dateStr, dow); totalSollMinutes += (sollHours - 4) * 60; workedDays++; } else { // Ganzer Urlaubstag vacationFullDays++; } } else { // Normaler Arbeitstag const sollHours = getDailySollHours(dateStr, dow); totalSollMinutes += sollHours * 60; workedDays++; processedDays++; } }); // Verwende die Ist-Stunden direkt aus der DB-Query (korrekt!) const totalIstMinutes = totalIstHoursFromDB * 60; // Berechne Überstunden ohne Offset const overtimeMinutesRaw = Math.round(totalIstMinutes - totalSollMinutes); // Addiere den Offset const overtimeMinutes = overtimeMinutesRaw + overtimeOffsetMinutes; const overtimeHours = Math.floor(Math.abs(overtimeMinutes) / 60); const overtimeMins = Math.abs(overtimeMinutes) % 60; const overtimeSign = overtimeMinutes >= 0 ? '+' : '-'; const formatted = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; return { minutes: overtimeMinutes, formatted }; } /** * Wochenübersicht für einen Benutzer abrufen * @param {number} userId - Benutzer-ID * @param {number} weekOffset - Wochen-Offset (0 = aktuelle Woche, -1 = letzte Woche, etc.) * @returns {Promise} Wochenübersicht mit Tagesdaten */ async getWeekOverview(userId, weekOffset = 0) { const uid = userId || this.defaultUserId; // Berechne Start und Ende der gewünschten Woche (Montag bis Sonntag) // Verwende lokales Datum, nicht UTC const now = new Date(); const year = now.getFullYear(); const month = now.getMonth(); const day = now.getDate(); const currentDay = now.getDay(); // 0 = Sonntag, 1 = Montag, ..., 6 = Samstag // Offset zu Montag berechnen // Montag ist Tag 1, wir wollen zum Montag der aktuellen Woche // Wenn Sonntag (0), dann ist das Ende der Woche, also -6 Tage zum Montag // Wenn Montag (1), dann 0 Tage // Wenn Dienstag (2), dann -1 Tag, etc. const daysToMonday = currentDay === 0 ? -6 : 1 - currentDay; // Berechne Montag der Woche const weekStart = new Date(year, month, day + daysToMonday + (weekOffset * 7)); weekStart.setHours(0, 0, 0, 0); // Berechne Sonntag der Woche const weekEnd = new Date(year, month, day + daysToMonday + (weekOffset * 7) + 6); weekEnd.setHours(23, 59, 59, 999); // Hole alle Worklog-Einträge für diese Woche const pairs = await worklogRepository.findPairsByUserInDateRange(uid, weekStart, weekEnd); // Hole auch alle einzelnen Einträge für Pausen const allEntries = await worklogRepository.findByDateRange(uid, weekStart, weekEnd); // Hole Timefix-Einträge für alle Worklog-IDs const allWorklogIds = [...new Set([ ...pairs.map(p => p.id), ...allEntries.map(e => e.id) ])]; const timefixMap = await worklogRepository.getTimefixesByWorklogIds(allWorklogIds); // Hole Vacation-Einträge für diese Woche const vacations = await worklogRepository.getVacationsByUserInDateRange(uid, weekStart, weekEnd); // Erstelle Map von Datum zu Vacation const vacationMap = new Map(); vacations.forEach(vac => { const vacDate = new Date(vac.date); const vacKey = `${vacDate.getUTCFullYear()}-${(vacDate.getUTCMonth()+1).toString().padStart(2,'0')}-${vacDate.getUTCDate().toString().padStart(2,'0')}`; vacationMap.set(vacKey, vac); }); // Hole Sick-Einträge für diese Woche const sickDays = await worklogRepository.getSickByUserInDateRange(uid, weekStart, weekEnd); // Erstelle Map von Datum zu Sick const sickMap = new Map(); sickDays.forEach(sick => { const sickDate = new Date(sick.date); const sickKey = `${sickDate.getUTCFullYear()}-${(sickDate.getUTCMonth()+1).toString().padStart(2,'0')}-${sickDate.getUTCDate().toString().padStart(2,'0')}`; sickMap.set(sickKey, sick); }); // Hole Feiertage für diese Woche (nur die für das User-Bundesland gelten) const holidays = await worklogRepository.getHolidaysInDateRange(weekStart, weekEnd, uid); // Erstelle Map von Datum zu Holiday const holidayMap = new Map(); holidays.forEach(holiday => { const holidayDate = new Date(holiday.date); const holidayKey = `${holidayDate.getUTCFullYear()}-${(holidayDate.getUTCMonth()+1).toString().padStart(2,'0')}-${holidayDate.getUTCDate().toString().padStart(2,'0')}`; holidayMap.set(holidayKey, { hours: holiday.hours || 8, description: holiday.description }); }); // Gruppiere Einträge nach Tagen const dayData = {}; // Initialisiere alle Wochentage const todayKey = `${year}-${(month+1).toString().padStart(2,'0')}-${day.toString().padStart(2,'0')}`; for (let i = 0; i < 7; i++) { const currentDay = new Date(weekStart.getFullYear(), weekStart.getMonth(), weekStart.getDate() + i); const dayKey = `${currentDay.getFullYear()}-${(currentDay.getMonth()+1).toString().padStart(2,'0')}-${currentDay.getDate().toString().padStart(2,'0')}`; dayData[dayKey] = { date: dayKey, name: this._getDayName(currentDay.getDay()), workBlocks: [], // Array für mehrere Arbeitszeitblöcke workTime: null, // Für Kompatibilität pauses: [], totalWorkTime: null, pauseTimes: [], netWorkTime: null, status: '', statusText: '', isToday: dayKey === todayKey }; } // Verarbeite gefundene Einträge - SAMMLE alle Arbeitsblöcke (Array.isArray(pairs) ? pairs : []).forEach(pair => { if (!pair.start_time) return; const startDate = new Date(pair.start_time); const dayKey = `${startDate.getFullYear()}-${(startDate.getMonth()+1).toString().padStart(2,'0')}-${startDate.getDate().toString().padStart(2,'0')}`; if (!dayData[dayKey]) return; const day = dayData[dayKey]; // Erstelle einen Arbeitsblock für diesen Pair const workBlock = {}; if (pair.end_time) { // Abgeschlossener Arbeitstag // WICHTIG: DB speichert lokale Zeit als UTC // Wir müssen die UTC-Komponenten direkt als lokale Zeit interpretieren let workStartUTC = new Date(pair.start_time); let workEndUTC = new Date(pair.end_time); // Prüfe auf Timefix-Korrekturen const startFix = timefixMap.get(pair.id)?.find(f => f.fix_type === 'start work'); const endFixEntry = allEntries.find(e => { const action = (typeof e.state === 'string' ? JSON.parse(e.state) : e.state)?.action || e.state; return action === 'stop work' && e.relatedTo_id === pair.id; }); const endFix = endFixEntry ? timefixMap.get(endFixEntry.id)?.find(f => f.fix_type === 'stop work') : null; // Verwende korrigierte Zeiten falls vorhanden const originalStartTime = workStartUTC; const originalEndTime = workEndUTC; if (startFix) { // DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation const fixTime = typeof startFix.fix_date_time === 'string' ? startFix.fix_date_time.replace(' ', 'T') + 'Z' : startFix.fix_date_time; workStartUTC = new Date(fixTime); } if (endFix) { // DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation const fixTime = typeof endFix.fix_date_time === 'string' ? endFix.fix_date_time.replace(' ', 'T') + 'Z' : endFix.fix_date_time; workEndUTC = new Date(fixTime); } // Parse Zeit-String (YYYY-MM-DD HH:MM:SS) direkt // tstamp ist jetzt ein String durch typeCast const parseTimeString = (timeStr) => { if (typeof timeStr === 'string') { const parts = timeStr.split(' ')[1]; // "HH:MM:SS" const [h, m] = parts.split(':').map(Number); return { hours: h, minutes: m }; } // Fallback für Date-Objekte (sollte nicht mehr vorkommen) return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; }; // Verwende korrigierte Zeiten für die Anzeige const start = parseTimeString(startFix ? workStartUTC : pair.start_time); const end = parseTimeString(endFix ? workEndUTC : pair.end_time); const startHours = start.hours; const startMinutes = start.minutes; const endHours = end.hours; const endMinutes = end.minutes; // Original-Zeiten für Tooltip const origStart = parseTimeString(originalStartTime); const origEnd = parseTimeString(originalEndTime); const originalStartHours = origStart.hours; const originalStartMinutes = origStart.minutes; const originalEndHours = origEnd.hours; const originalEndMinutes = origEnd.minutes; // Berechne Arbeitszeit (Dauer bleibt gleich, da beide gleich verschoben sind) const workStartMs = typeof pair.start_time === 'string' ? new Date(pair.start_time).getTime() : workStartUTC.getTime(); const workEndMs = typeof pair.end_time === 'string' ? new Date(pair.end_time).getTime() : workEndUTC.getTime(); const workDuration = workEndMs - workStartMs; const workHours = Math.floor(workDuration / (1000 * 60 * 60)); const workMinutes = Math.floor((workDuration % (1000 * 60 * 60)) / (1000 * 60)); workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - ${endHours.toString().padStart(2,'0')}:${endMinutes.toString().padStart(2,'0')}`; workBlock.totalWorkTime = `${workHours}:${workMinutes.toString().padStart(2, '0')}`; workBlock.workTimeFixed = !!(startFix || endFix); if (startFix || endFix) { workBlock.workTimeOriginal = `${originalStartHours.toString().padStart(2,'0')}:${originalStartMinutes.toString().padStart(2,'0')} - ${originalEndHours.toString().padStart(2,'0')}:${originalEndMinutes.toString().padStart(2,'0')}`; } // Berechne Pausen NUR für diesen spezifischen Arbeitsblock // Finde alle Einträge, die zu diesem pair.id gehören (über relatedTo_id) const blockEntries = allEntries.filter(e => { // Parse state if it's a JSON string let state = e.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (err) { // state bleibt als String } } const action = state?.action || state; // Einträge gehören zum Block wenn: // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) // 2. Oder sie stop work sind mit relatedTo_id = pair.id if (action === 'start pause' && e.relatedTo_id === pair.id) { return true; // Pause gehört zu diesem Arbeitsblock } if (action === 'stop pause') { // Finde das zugehörige start pause const startPause = allEntries.find(sp => { const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; const spAction = spState?.action || spState; return spAction === 'start pause' && sp.id === e.relatedTo_id; }); if (startPause && startPause.relatedTo_id === pair.id) { return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört } } return false; }); // Finde Pausen-Paare NUR für diesen Block const pausePairs = []; const pauseStarts = {}; blockEntries.forEach(entry => { // Parse state if it's a JSON string let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // state bleibt als String } } const action = state?.action || state; if (action === 'start pause') { pauseStarts[entry.id] = entry; } else if (action === 'stop pause' && entry.relatedTo_id) { const startPause = pauseStarts[entry.relatedTo_id]; if (startPause) { // Verwende die tatsächlichen Zeitwerte let pStartTime = startPause.tstamp; let pEndTime = entry.tstamp; // Prüfe auf Timefix-Korrekturen const pauseStartFix = timefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause'); const pauseEndFix = timefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause'); const originalPStartTime = pStartTime; const originalPEndTime = pEndTime; if (pauseStartFix) { // DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation pStartTime = typeof pauseStartFix.fix_date_time === 'string' ? pauseStartFix.fix_date_time.replace(' ', 'T') + 'Z' : pauseStartFix.fix_date_time; } if (pauseEndFix) { // DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation pEndTime = typeof pauseEndFix.fix_date_time === 'string' ? pauseEndFix.fix_date_time.replace(' ', 'T') + 'Z' : pauseEndFix.fix_date_time; } // Berechne Dauer (aus Strings) const pStartMs = new Date(pStartTime).getTime(); const pEndMs = new Date(pEndTime).getTime(); const duration = pEndMs - pStartMs; // Parse Pausen-Zeiten const pStart = parseTimeString(pStartTime); const pEnd = parseTimeString(pEndTime); const pStartHours = pStart.hours; const pStartMinutes = pStart.minutes; const pEndHours = pEnd.hours; const pEndMinutes = pEnd.minutes; const pauseData = { start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`, duration: duration, fixed: !!(pauseStartFix || pauseEndFix) }; if (pauseStartFix || pauseEndFix) { const origStart = parseTimeString(originalPStartTime); const origEnd = parseTimeString(originalPEndTime); pauseData.original = `${origStart.hours.toString().padStart(2,'0')}:${origStart.minutes.toString().padStart(2,'0')} - ${origEnd.hours.toString().padStart(2,'0')}:${origEnd.minutes.toString().padStart(2,'0')}`; } pausePairs.push(pauseData); delete pauseStarts[entry.relatedTo_id]; } } }); // Berechne Pausenzeiten mit Korrektur-Informationen workBlock.pauses = pausePairs.map(p => ({ time: `${p.start} - ${p.end}`, fixed: p.fixed, original: p.original })); workBlock.pauseTimes = pausePairs.map(p => { const pauseMinutes = Math.floor(p.duration / (1000 * 60)); const pH = Math.floor(pauseMinutes / 60); const pM = pauseMinutes % 60; return { time: `${pH}:${pM.toString().padStart(2, '0')}`, fixed: p.fixed, original: p.original }; }); const totalPauseDuration = pausePairs.reduce((sum, p) => sum + p.duration, 0); const netWorkDuration = workDuration - totalPauseDuration; const netHours = Math.floor(netWorkDuration / (1000 * 60 * 60)); const netMinutes = Math.floor((netWorkDuration % (1000 * 60 * 60)) / (1000 * 60)); workBlock.netWorkTime = `${netHours}:${netMinutes.toString().padStart(2, '0')}`; workBlock.completed = true; // Füge Arbeitsblock zum Tag hinzu day.workBlocks.push(workBlock); // Für Kompatibilität: Setze auch die alten Felder (letzter Block) day.workTime = workBlock.workTime; day.workTimeFixed = workBlock.workTimeFixed; day.workTimeOriginal = workBlock.workTimeOriginal; day.pauses = workBlock.pauses; day.totalWorkTime = workBlock.totalWorkTime; day.pauseTimes = workBlock.pauseTimes; day.netWorkTime = workBlock.netWorkTime; day.status = 'complete'; day.statusText = 'Abgeschlossen'; } else { // Laufender Arbeitstag // Prüfe auf Timefix-Korrektur für Start const startFix = timefixMap.get(pair.id)?.find(f => f.fix_type === 'start work'); const parseTimeString = (timeStr) => { if (typeof timeStr === 'string') { const parts = timeStr.split(' ')[1]; // "HH:MM:SS" const [h, m] = parts.split(':').map(Number); return { hours: h, minutes: m }; } // Fallback für Date-Objekte (sollte nicht mehr vorkommen) return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; }; // Verwende korrigierte Zeit falls vorhanden let displayStartTime = pair.start_time; if (startFix) { // DB speichert UTC - füge 'Z' hinzu für korrekte Interpretation const fixTime = typeof startFix.fix_date_time === 'string' ? startFix.fix_date_time.replace(' ', 'T') + 'Z' : startFix.fix_date_time; displayStartTime = new Date(fixTime); } const start = parseTimeString(displayStartTime); const startHours = start.hours; const startMinutes = start.minutes; workBlock.workTime = `${startHours.toString().padStart(2,'0')}:${startMinutes.toString().padStart(2,'0')} - jetzt`; workBlock.workTimeFixed = !!startFix; workBlock.completed = false; if (startFix) { // Original-Zeit für Tooltip const origStart = parseTimeString(pair.start_time); workBlock.workTimeOriginal = `${origStart.hours.toString().padStart(2,'0')}:${origStart.minutes.toString().padStart(2,'0')} - jetzt`; } // Prüfe auf Pausen NUR für diesen laufenden Arbeitsblock const blockEntries = allEntries.filter(e => { // Parse state if it's a JSON string let state = e.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (err) { // state bleibt als String } } const action = state?.action || state; // Einträge gehören zum Block wenn: // 1. Sie start/stop pause sind UND ihr relatedTo_id auf pair.id zeigt (direkt oder indirekt) if (action === 'start pause' && e.relatedTo_id === pair.id) { return true; // Pause gehört zu diesem Arbeitsblock } if (action === 'stop pause') { // Finde das zugehörige start pause const startPause = allEntries.find(sp => { const spState = typeof sp.state === 'string' ? JSON.parse(sp.state) : sp.state; const spAction = spState?.action || spState; return spAction === 'start pause' && sp.id === e.relatedTo_id; }); if (startPause && startPause.relatedTo_id === pair.id) { return true; // Stop pause gehört zu einem start pause, das zu diesem Block gehört } } return false; }); // Finde laufende Pause const pauseStarts = {}; const pausePairs = []; blockEntries.forEach(entry => { let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // ignore } } const action = state?.action || state; if (action === 'start pause') { pauseStarts[entry.id] = entry; } else if (action === 'stop pause' && entry.relatedTo_id) { const startPause = pauseStarts[entry.relatedTo_id]; if (startPause) { // Abgeschlossene Pause const pStartUTC = new Date(startPause.tstamp); const pEndUTC = new Date(entry.tstamp); const duration = pEndUTC.getTime() - pStartUTC.getTime(); const pStart = parseTimeString(startPause.tstamp); const pEnd = parseTimeString(entry.tstamp); const pStartHours = pStart.hours; const pStartMinutes = pStart.minutes; const pEndHours = pEnd.hours; const pEndMinutes = pEnd.minutes; pausePairs.push({ start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, end: `${pEndHours.toString().padStart(2,'0')}:${pEndMinutes.toString().padStart(2,'0')}`, duration: duration, fixed: false }); delete pauseStarts[entry.relatedTo_id]; // Lösche das START pause, nicht das STOP pause } } }); // Füge laufende Pausen hinzu Object.values(pauseStarts).forEach(startPause => { const parseTimeString = (timeStr) => { if (typeof timeStr === 'string') { const parts = timeStr.split(' ')[1]; const [h, m] = parts.split(':').map(Number); return { hours: h, minutes: m }; } // Fallback für Date-Objekte (sollte nicht mehr vorkommen) return { hours: timeStr.getHours(), minutes: timeStr.getMinutes() }; }; const pStart = parseTimeString(startPause.tstamp); const pStartHours = pStart.hours; const pStartMinutes = pStart.minutes; pausePairs.push({ start: `${pStartHours.toString().padStart(2,'0')}:${pStartMinutes.toString().padStart(2,'0')}`, end: 'läuft', duration: null, fixed: false, running: true }); }); // Setze Pausen-Daten für diesen Block workBlock.pauses = pausePairs.map(p => ({ time: p.end === 'läuft' ? `${p.start} - jetzt` : `${p.start} - ${p.end}`, fixed: p.fixed, running: p.running })); workBlock.pauseTimes = pausePairs.filter(p => p.duration).map(p => { const pauseMinutes = Math.floor(p.duration / (1000 * 60)); const pH = Math.floor(pauseMinutes / 60); const pM = pauseMinutes % 60; return { time: `${pH}:${pM.toString().padStart(2, '0')}`, fixed: false }; }); // Füge laufenden Block zum Tag hinzu day.workBlocks.push(workBlock); // Für Kompatibilität: Setze auch die alten Felder (letzter Block) day.workTime = workBlock.workTime; day.pauses = workBlock.pauses; day.pauseTimes = workBlock.pauseTimes; day.status = 'running'; day.statusText = 'Läuft'; } }); // Nach dem Sammeln aller Blöcke: Berechne Gesamtwerte pro Tag Object.values(dayData).forEach(day => { if (day.workBlocks.length === 0) return; // Berechne Gesamt-Arbeitszeit und -Nettozeit let totalWorkMinutes = 0; let totalNetMinutes = 0; day.workBlocks.forEach(block => { if (block.totalWorkTime) { const [h, m] = block.totalWorkTime.split(':').map(Number); totalWorkMinutes += h * 60 + m; } if (block.netWorkTime) { const [h, m] = block.netWorkTime.split(':').map(Number); totalNetMinutes += h * 60 + m; } }); // Setze Gesamtwerte (für Wochensumme) if (totalWorkMinutes > 0) { const tH = Math.floor(totalWorkMinutes / 60); const tM = totalWorkMinutes % 60; day.totalWorkTime = `${tH}:${tM.toString().padStart(2, '0')}`; } if (totalNetMinutes > 0) { const nH = Math.floor(totalNetMinutes / 60); const nM = totalNetMinutes % 60; day.netWorkTime = `${nH}:${nM.toString().padStart(2, '0')}`; } // NICHT die Pausen auf Day-Level setzen - sie sind bereits in den Blöcken! // Das Frontend zeigt die Blöcke einzeln an, nicht die Tag-Level-Pausen. // Status basierend auf Blöcken const hasRunning = day.workBlocks.some(b => !b.completed); const hasCompleted = day.workBlocks.some(b => b.completed); if (hasRunning) { day.status = 'running'; day.statusText = 'Läuft'; } else if (hasCompleted) { day.status = 'complete'; day.statusText = 'Abgeschlossen'; } }); // Markiere Wochenenden, integriere Urlaub, Krankheit und Feiertage Object.values(dayData).forEach(day => { const dayOfWeek = new Date(day.date).getDay(); // Sammle alle Ereignisse für diesen Tag const events = []; // Prüfe auf Krankheit const sick = sickMap.get(day.date); if (sick) { day.sick = { hours: 8, type: sick.sick_type }; const sickLabels = { 'self': 'Krank', 'child': 'Kind krank', 'parents': 'Eltern krank', 'partner': 'Partner krank' }; events.push(sickLabels[sick.sick_type] || 'Krank'); // Bei Krankheit: Lösche geloggte Arbeitszeit day.workTime = null; day.pauses = []; day.totalWorkTime = null; day.pauseTimes = []; day.netWorkTime = null; } // Prüfe auf Feiertag const holiday = holidayMap.get(day.date); if (holiday) { day.holiday = { hours: holiday.hours, description: holiday.description }; events.push(holiday.description || 'Feiertag'); } // Prüfe auf Urlaub const vacation = vacationMap.get(day.date); if (vacation) { const isHalfDay = vacation.half_day === 1; const vacationHours = isHalfDay ? 4 : 8; day.vacation = { hours: vacationHours, halfDay: isHalfDay }; events.push(isHalfDay ? 'Urlaub (halber Tag)' : 'Urlaub'); } // Prüfe auf Wochenende const isWeekend = (dayOfWeek === 0 || dayOfWeek === 6); if (isWeekend) { events.push('Wochenende'); } // Setze Status basierend auf gesammelten Ereignissen if (events.length > 0) { if (day.workTime) { // Ereignis(se) + Arbeit day.statusText = events.join(' + ') + ' + Arbeit'; day.status = 'mixed-work'; } else { // Nur Ereignis(se), keine Arbeit day.statusText = events.join(' + '); // Status basierend auf primärem Ereignis if (sick) { day.status = 'sick'; } else if (holiday) { day.status = 'holiday'; } else if (vacation) { day.status = vacation.halfDay ? 'vacation-half' : 'vacation-full'; } else if (isWeekend) { day.status = 'weekend'; } } } else if (isWeekend) { // Nur Wochenende, keine anderen Events day.status = 'weekend'; day.statusText = 'Wochenende'; } }); // Berechne Wochensumme (Arbeit + Urlaub + Krankheit + Feiertage) let totalMinutes = 0; Object.values(dayData).forEach(day => { // Krankheitsstunden (haben Vorrang) if (day.sick) { totalMinutes += day.sick.hours * 60; } else { // Nettoarbeitszeit if (day.netWorkTime) { const [hours, minutes] = day.netWorkTime.split(':').map(Number); totalMinutes += hours * 60 + minutes; } // Feiertagsstunden if (day.holiday) { totalMinutes += day.holiday.hours * 60; } // Urlaubsstunden if (day.vacation) { totalMinutes += day.vacation.hours * 60; } } }); const weekTotalHours = Math.floor(totalMinutes / 60); const weekTotalMinutes = totalMinutes % 60; const weekTotal = `${weekTotalHours}:${weekTotalMinutes.toString().padStart(2, '0')}`; // Berechne nur die Arbeitszeit (ohne arbeitsfreie Tage) let workMinutes = 0; Object.values(dayData).forEach(day => { if (day.netWorkTime && !day.vacation && !day.sick && !day.holiday) { const [hours, minutes] = day.netWorkTime.split(':').map(Number); workMinutes += hours * 60 + minutes; } }); const workHours = Math.floor(workMinutes / 60); const workMins = workMinutes % 60; const workTotal = `${workHours}:${workMins.toString().padStart(2, '0')}`; // Berechne arbeitsfreie Stunden für die Woche (separat für Anzeige) let nonWorkingMinutes = 0; let nonWorkingDays = 0; const nonWorkingDetails = []; Object.values(dayData).forEach(day => { if (day.vacation && day.vacation.hours > 0) { nonWorkingMinutes += day.vacation.hours * 60; nonWorkingDays++; nonWorkingDetails.push({ date: day.date, type: 'Urlaub', hours: day.vacation.hours }); } if (day.sick && day.sick.hours > 0) { nonWorkingMinutes += day.sick.hours * 60; nonWorkingDays++; nonWorkingDetails.push({ date: day.date, type: 'Krankheit', hours: day.sick.hours }); } if (day.holiday && day.holiday.hours > 0) { nonWorkingMinutes += day.holiday.hours * 60; nonWorkingDays++; nonWorkingDetails.push({ date: day.date, type: 'Feiertag', hours: day.holiday.hours }); } }); const nonWorkingHours = Math.floor(nonWorkingMinutes / 60); const nonWorkingMins = nonWorkingMinutes % 60; const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`; // Gesamtsumme = Arbeitszeit + arbeitsfreie Zeit const totalAllMinutes = workMinutes + nonWorkingMinutes; const totalAllHours = Math.floor(totalAllMinutes / 60); const totalAllMins = totalAllMinutes % 60; const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`; return { weekStart: weekStart.toISOString().split('T')[0], weekEnd: weekEnd.toISOString().split('T')[0], weekOffset, days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)), weekTotal: workTotal, // Nur Arbeitszeit nonWorkingTotal, nonWorkingDays, nonWorkingDetails, totalAll // Arbeitszeit + arbeitsfreie Zeit }; } /** * Aktuellen Status/Zustand des Benutzers abrufen * @param {number} userId - Benutzer-ID * @returns {Promise} Letzter Action-Status oder null */ async getCurrentState(userId) { const uid = userId || this.defaultUserId; // Hole letzten Worklog-Eintrag (inkl. Timefix-Korrekturen) const lastEntry = await worklogRepository.findLatestByUser(uid); if (!lastEntry) { return null; } // Parse state let state = lastEntry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // state bleibt als String } } const action = state?.action || state; // Prüfe auf Timefix-Korrektur für diesen Eintrag const timefixMap = await worklogRepository.getTimefixesByWorklogIds([lastEntry.id]); const timefix = timefixMap.get(lastEntry.id)?.[0]; if (timefix) { // Verwende korrigierten Action-Typ return timefix.fix_type; } return action; } /** * Stempeln (Clock-Aktion durchführen) * @param {number} userId - Benutzer-ID * @param {string} action - Aktion: 'start work', 'stop work', 'start pause', 'stop pause' * @returns {Promise} Erstellter Worklog-Eintrag */ async clock(userId, action) { const uid = userId || this.defaultUserId; // Validiere Action const validActions = ['start work', 'stop work', 'start pause', 'stop pause']; if (!validActions.includes(action)) { throw new Error('Ungültige Aktion'); } // Hole aktuellen Status const currentState = await this.getCurrentState(uid); // Validiere State-Transition const isValidTransition = this._validateTransition(currentState, action); if (!isValidTransition) { throw new Error(`Ungültiger Übergang: ${currentState} -> ${action}`); } // Erstelle Worklog-Eintrag const relatedToId = await this._getRelatedToId(currentState, action, uid); // Die DB speichert UTC-Zeit (ohne TZ-Info im String) // Format: YYYY-MM-DD HH:MM:SS (immer UTC) // Das Frontend konvertiert dann zu lokaler Zeit für die Anzeige const now = new Date(); const year = now.getUTCFullYear(); const month = String(now.getUTCMonth() + 1).padStart(2, '0'); const day = String(now.getUTCDate()).padStart(2, '0'); const hours = String(now.getUTCHours()).padStart(2, '0'); const minutes = String(now.getUTCMinutes()).padStart(2, '0'); const seconds = String(now.getUTCSeconds()).padStart(2, '0'); const utcTimeString = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; const entry = await worklogRepository.create({ user_id: uid, state: action, // Speichere nur den Action-String, nicht als JSON tstamp: utcTimeString, // UTC-Zeit als String relatedTo_id: relatedToId }); return entry; } /** * Validiert ob eine State-Transition erlaubt ist * @private */ _validateTransition(currentState, newAction) { const transitions = { 'null': ['start work'], 'stop work': ['start work'], 'start work': ['stop work', 'start pause'], 'stop pause': ['stop work', 'start pause'], 'start pause': ['stop pause'] }; const allowedActions = transitions[currentState || 'null'] || []; return allowedActions.includes(newAction); } /** * Ermittelt die relatedTo_id für einen neuen Eintrag * @private */ async _getRelatedToId(currentState, newAction, userId) { if (newAction === 'start work') { return null; // Start work hat keine Referenz } // stop work und stop pause referenzieren den entsprechenden Start if (newAction === 'stop work') { // Finde letzten 'start work' ohne 'stop work' return await this._findLastUnpairedStart(userId, 'start work'); } if (newAction === 'stop pause') { // Finde letzten 'start pause' ohne 'stop pause' return await this._findLastUnpairedStart(userId, 'start pause'); } if (newAction === 'start pause') { // start pause referenziert den laufenden 'start work' return await this._findLastUnpairedStart(userId, 'start work'); } return null; } /** * Findet den letzten Start-Eintrag ohne passendes End * @private */ async _findLastUnpairedStart(userId, startAction) { const allEntries = await worklogRepository.findByUser(userId); // Finde den letzten Start ohne Stop for (let i = allEntries.length - 1; i >= 0; i--) { const entry = allEntries[i]; let state = entry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // ignore - state bleibt als String } } const action = state?.action || state; if (action === startAction) { // Prüfe ob es einen passenden Stop gibt // WICHTIG: Nur nach dem entsprechenden STOP-Action suchen, nicht nach Pausen! const stopAction = startAction === 'start work' ? 'stop work' : 'stop pause'; const hasStop = allEntries.some(e => { // Parse state für diesen Eintrag let eState = e.state; if (typeof eState === 'string') { try { eState = JSON.parse(eState); } catch (err) { // ignore } } const eAction = eState?.action || eState; // Prüfe ob dieser Eintrag ein STOP ist, der auf unseren START zeigt return eAction === stopAction && e.relatedTo_id === entry.id; }); if (!hasStop) { return entry.id; } } } return null; } /** * Hilfsmethode: Wochentag-Namen * @private * @param {number} dayOfWeek - 0 = Sonntag, 1 = Montag, ..., 6 = Samstag */ _getDayName(dayOfWeek) { const days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; return days[dayOfWeek]; } /** * Aktuell laufenden Timer abrufen * @param {number} userId - Benutzer-ID (optional) * @returns {Promise} Der laufende Zeiteintrag oder null */ async getRunningEntry(userId = null) { const uid = userId || this.defaultUserId; // Hole letzten Eintrag const lastEntry = await worklogRepository.findLatestByUser(uid); if (!lastEntry) { return null; } // Parse state let state = lastEntry.state; if (typeof state === 'string') { try { state = JSON.parse(state); } catch (e) { // state bleibt als String } } const action = state?.action || state; // Wenn letzter Eintrag "stop work" oder null ist, läuft nichts if (!action || action === 'stop work') { return null; } // Finde den zugehörigen "start work" const startWorkId = await this._findLastUnpairedStart(uid, 'start work'); if (!startWorkId) { return null; } // Hole alle Einträge seit dem Start const allEntries = await worklogRepository.findByUser(uid); const startWorkEntry = allEntries.find(e => e.id === startWorkId); if (!startWorkEntry) { return null; } // Sammle alle abgeschlossenen Pausen const pauseDurations = []; let currentPauseStart = null; // Finde alle Pausen-Paare const relevantEntries = allEntries.filter(e => { const eDate = new Date(e.tstamp); const startDate = new Date(startWorkEntry.tstamp); return eDate >= startDate; }); const pauseStarts = {}; // Hole Timefixes für alle relevanten Einträge (inklusive Pausen) const allEntryIds = relevantEntries.map(e => e.id); const pauseTimefixMap = await worklogRepository.getTimefixesByWorklogIds(allEntryIds); relevantEntries.forEach(entry => { let eState = entry.state; if (typeof eState === 'string') { try { eState = JSON.parse(eState); } catch (e) { // ignore } } const eAction = eState?.action || eState; if (eAction === 'start pause') { pauseStarts[entry.id] = entry; } else if (eAction === 'stop pause' && entry.relatedTo_id) { const startPause = pauseStarts[entry.relatedTo_id]; if (startPause) { // Prüfe auf Timefix-Korrekturen für Pausen const pauseStartFix = pauseTimefixMap.get(startPause.id)?.find(f => f.fix_type === 'start pause'); const pauseEndFix = pauseTimefixMap.get(entry.id)?.find(f => f.fix_type === 'stop pause'); // Verwende korrigierte Zeiten falls vorhanden const pStartTime = pauseStartFix?.fix_date_time || startPause.tstamp; const pEndTime = pauseEndFix?.fix_date_time || entry.tstamp; const duration = new Date(pEndTime).getTime() - new Date(pStartTime).getTime(); pauseDurations.push(duration); delete pauseStarts[entry.relatedTo_id]; } } }); // Wenn noch ein pauseStart übrig ist, ist das die laufende Pause const runningPauseIds = Object.keys(pauseStarts); if (runningPauseIds.length > 0) { const pauseId = parseInt(runningPauseIds[0]); const pauseEntry = pauseStarts[pauseId]; // Prüfe auf Timefix-Korrektur für laufende Pause const currentPauseTimefix = pauseTimefixMap.get(pauseId)?.find(f => f.fix_type === 'start pause'); currentPauseStart = currentPauseTimefix?.fix_date_time || pauseEntry.tstamp; } // Prüfe auf Timefix-Korrektur für die Start-Zeit (timefixMap bereits geholt in pauseTimefixMap) const startTimefix = pauseTimefixMap.get(startWorkId)?.find(f => f.fix_type === 'start work'); let displayStartTime = startWorkEntry.tstamp; if (startTimefix && startTimefix.fix_date_time) { // Verwende korrigierte Zeit displayStartTime = startTimefix.fix_date_time; } // Stelle sicher, dass startTime ein String ist (kein Date-Objekt) // Konvertiere UTC zu lokaler Zeit für Frontend let startTimeStr; if (typeof displayStartTime === 'string') { // Wenn es ein UTC-String ist, konvertiere zu lokaler Zeit const utcDate = new Date(displayStartTime); const year = utcDate.getFullYear(); const month = String(utcDate.getMonth() + 1).padStart(2, '0'); const day = String(utcDate.getDate()).padStart(2, '0'); const hours = String(utcDate.getHours()).padStart(2, '0'); const minutes = String(utcDate.getMinutes()).padStart(2, '0'); const seconds = String(utcDate.getSeconds()).padStart(2, '0'); startTimeStr = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; } else { startTimeStr = displayStartTime.toISOString(); } // Stelle sicher, dass currentPauseStart auch ein String ist const currentPauseStartStr = currentPauseStart ? (typeof currentPauseStart === 'string' ? currentPauseStart : currentPauseStart.toISOString()) : null; const result = { id: startWorkId, startTime: startTimeStr, // Explizit als String endTime: null, description: (state?.description || ''), project: (state?.project || 'Allgemein'), duration: null, isRunning: true, userId: uid, pauses: pauseDurations, currentPauseStart: currentPauseStartStr }; return result; } /** * Alle Einträge nach Projekt filtern * @param {string} projectName - Name des Projekts * @param {number} userId - Benutzer-ID (optional) * @returns {Promise} Gefilterte Liste der Zeiteinträge */ async getEntriesByProject(projectName, userId = null) { const uid = userId || this.defaultUserId; const pairs = await worklogRepository.findPairsByUser(uid); const safePairs = Array.isArray(pairs) ? pairs : []; return safePairs .map(pair => this._mapWorklogPairToTimeEntry(pair)) .filter(entry => entry.project.toLowerCase() === projectName.toLowerCase() ); } /** * Alle Einträge in einem Datumsbereich abrufen * @param {Date} startDate - Startdatum * @param {Date} endDate - Enddatum * @param {number} userId - Benutzer-ID (optional) * @returns {Promise} Gefilterte Liste der Zeiteinträge */ async getEntriesByDateRange(startDate, endDate, userId = null) { const uid = userId || this.defaultUserId; const worklogs = await worklogRepository.findByDateRange(uid, startDate, endDate); // Zu Paaren gruppieren const pairs = []; const startEntries = worklogs.filter(w => w.relatedTo_id === null); for (const start of startEntries) { const end = worklogs.find(w => w.relatedTo_id === start.id); let duration = null; if (end) { const startTime = new Date(start.tstamp); const endTime = new Date(end.tstamp); duration = Math.floor((endTime - startTime) / 1000); } pairs.push({ start_id: start.id, start_time: start.tstamp, start_state: start.state, end_id: end ? end.id : null, end_time: end ? end.tstamp : null, end_state: end ? end.state : null, duration }); } return pairs.map(pair => this._mapWorklogPairToTimeEntry(pair)); } /** * Worklog-Paar zu TimeEntry-Format konvertieren (privat) * @private */ _mapWorklogPairToTimeEntry(pair) { let startStateData, endStateData; try { startStateData = JSON.parse(pair.start_state); } catch { startStateData = { project: 'Allgemein', description: '' }; } try { endStateData = pair.end_state ? JSON.parse(pair.end_state) : null; } catch { endStateData = null; } return { id: pair.start_id, startTime: pair.start_time, endTime: pair.end_time || null, description: (endStateData && endStateData.description) || startStateData.description || '', project: (endStateData && endStateData.project) || startStateData.project || 'Allgemein', duration: pair.duration || null, isRunning: !pair.end_time, userId: null // Wird vom Repository nicht zurückgegeben im Pair }; } } // Singleton-Instanz exportieren module.exports = new TimeEntryService();