Files
stechuhr3/backend/src/services/TimeEntryService.js

2430 lines
87 KiB
JavaScript

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<Array>} 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<Object|null>} 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<Object>} 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<Object>} 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<boolean>} 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<Object>} 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<Object>} { 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<Object>} 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<string|null>} 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<Object>} 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<Object|null>} 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<Array>} 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<Array>} 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();