diff --git a/backend/src/repositories/WorklogRepository.js b/backend/src/repositories/WorklogRepository.js index 0339a68..9c1fee6 100755 --- a/backend/src/repositories/WorklogRepository.js +++ b/backend/src/repositories/WorklogRepository.js @@ -1,6 +1,11 @@ const database = require('../config/database'); const { Op } = require('sequelize'); +// DATEONLY-Werte als Kalendertage behandeln: lokale setDate-Schritte auf +// UTC-Mitternacht verschieben beim Sommerzeitwechsel den letzten Tag. +const localDateKey = date => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + + /** * Repository für Worklog-Datenbankzugriff * Verwendet Sequelize ORM @@ -171,6 +176,8 @@ class WorklogRepository { async getVacationsByUserInDateRange(userId, startDate, endDate) { const { Vacation } = database.getModels(); + const startKey = localDateKey(startDate); + const endKey = localDateKey(endDate); try { // Hole alle Vacation-Einträge, die sich mit dem Datumsbereich überschneiden const vacations = await Vacation.findAll({ @@ -179,18 +186,18 @@ class WorklogRepository { [Op.or]: [ { first_day: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, { last_day: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, { [Op.and]: [ - { first_day: { [Op.lte]: startDate } }, - { last_day: { [Op.gte]: endDate } } + { first_day: { [Op.lte]: startKey } }, + { last_day: { [Op.gte]: endKey } } ] } ] @@ -207,11 +214,11 @@ class WorklogRepository { const last = new Date(vac.last_day); // Iteriere über alle Tage im Urlaubsbereich - for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) { + for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) { // Nur Tage hinzufügen, die im gewünschten Bereich liegen - if (d >= startDate && d <= endDate) { + if (d.toISOString().slice(0, 10) >= startKey && d.toISOString().slice(0, 10) <= endKey) { // Überspringe Wochenenden (Samstag=6, Sonntag=0) - const dayOfWeek = d.getDay(); + const dayOfWeek = d.getUTCDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { expandedVacations.push({ date: new Date(d), @@ -240,6 +247,8 @@ class WorklogRepository { async getSickByUserInDateRange(userId, startDate, endDate) { const { Sick, SickType } = database.getModels(); + const startKey = localDateKey(startDate); + const endKey = localDateKey(endDate); try { // Hole alle Sick-Einträge, die sich mit dem Datumsbereich überschneiden const sickEntries = await Sick.findAll({ @@ -248,18 +257,18 @@ class WorklogRepository { [Op.or]: [ { first_day: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, { last_day: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, { [Op.and]: [ - { first_day: { [Op.lte]: startDate } }, - { last_day: { [Op.gte]: endDate } } + { first_day: { [Op.lte]: startKey } }, + { last_day: { [Op.gte]: endKey } } ] } ] @@ -281,11 +290,11 @@ class WorklogRepository { const sickTypeDesc = sick.sickType?.description || 'self'; // Iteriere über alle Tage im Krankheitsbereich - for (let d = new Date(first); d <= last; d.setDate(d.getDate() + 1)) { + for (let d = new Date(first); d <= last; d.setUTCDate(d.getUTCDate() + 1)) { // Nur Tage hinzufügen, die im gewünschten Bereich liegen - if (d >= startDate && d <= endDate) { + if (d.toISOString().slice(0, 10) >= startKey && d.toISOString().slice(0, 10) <= endKey) { // Überspringe Wochenenden (Samstag=6, Sonntag=0) - const dayOfWeek = d.getDay(); + const dayOfWeek = d.getUTCDay(); if (dayOfWeek !== 0 && dayOfWeek !== 6) { expandedSick.push({ date: new Date(d), @@ -358,7 +367,7 @@ class WorklogRepository { where: { user_id: userId, tstamp: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, order: [['tstamp', 'ASC']], @@ -429,7 +438,7 @@ class WorklogRepository { where: { user_id: userId, tstamp: { - [Op.between]: [startDate, endDate] + [Op.between]: [startKey, endKey] } }, order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt diff --git a/backend/src/services/TimeEntryService.js b/backend/src/services/TimeEntryService.js index a1d1d6d..fad6969 100755 --- a/backend/src/services/TimeEntryService.js +++ b/backend/src/services/TimeEntryService.js @@ -275,7 +275,6 @@ class TimeEntryService { const userDailyHours = user?.daily_hours || 8; let currentlyWorked = null; - let allEntries = []; // Hole laufenden Eintrag const runningEntry = await this.getRunningEntry(uid); @@ -284,13 +283,18 @@ class TimeEntryService { 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); + const todayBlocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid)) + .filter(block => { + const start = new Date(block.start.effectiveTime); + return start >= todayStart && start <= todayEnd; + }); + if (runningEntry) { // Arbeitszeit wird aus der korrigierten Ereignisfolge gebildet. Damit // darf eine nachträglich umtypisierte Buchung keine Phantompause mehr // erzeugen. - const blocks = this._buildEffectiveWorkBlocks(await this._getEffectiveEntries(uid)); let totalWorkedMs = 0; - for (const block of blocks) { + for (const block of todayBlocks) { const start = new Date(block.start.effectiveTime); if (start < todayStart || start > todayEnd) continue; const endMs = block.end ? new Date(block.end.effectiveTime).getTime() : now.getTime(); @@ -419,38 +423,11 @@ class TimeEntryService { 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)); - } + const takenBreakMs = todayBlocks.reduce((sum, block) => sum + + block.pauses.reduce((pauseSum, pause) => pauseSum + + new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0) + + (block.runningPause ? now - new Date(block.runningPause.effectiveTime) : 0), 0); + alreadyTakenBreakMinutes = Math.floor(takenBreakMs / 60000); } // Fehlende Pausenzeit @@ -596,8 +573,13 @@ class TimeEntryService { const overtime = `${overtimeSign}${overtimeHours}:${overtimeMins.toString().padStart(2, '0')}`; // Wochenarbeitszeit - const weekWorktimeHours = Math.floor(weekIstMinutes / 60); - const weekWorktimeMins = weekIstMinutes % 60; + const weekWorkMinutes = weekData.days.reduce((sum, day) => { + if (!day.netWorkTime) return sum; + const [h, m] = day.netWorkTime.split(':').map(Number); + return sum + h * 60 + m; + }, 0); + const weekWorktimeHours = Math.floor(weekWorkMinutes / 60); + const weekWorktimeMins = weekWorkMinutes % 60; const weekWorktime = `${weekWorktimeHours}:${weekWorktimeMins.toString().padStart(2, '0')}`; // Berechne "Offen für Woche" @@ -718,7 +700,7 @@ class TimeEntryService { const openH = parseInt(openParts[0]); const openM = parseInt(openParts[1]); const openS = parseInt(openParts[2] || 0); - const openMinutes = openH * 60 + openM; + const openMinutes = openH * 60 + openM + openS / 60; // Aktuelle Zeit const currentTime = new Date(); @@ -1311,6 +1293,7 @@ class TimeEntryService { })) .sort((a, b) => new Date(a.effectiveTime) - new Date(b.effectiveTime) || a.id - b.id); const pairs = this._buildEffectiveWorkBlocks(effectiveEntries).map(block => ({ + effectiveBlock: block, id: block.start.id, end_id: block.end?.id || null, start_time: block.start.tstamp, @@ -1738,6 +1721,18 @@ class TimeEntryService { }; }); + const block = pair.effectiveBlock; + const elapsedMs = Math.max(0, now - new Date(block.start.effectiveTime)); + const pauseMs = block.pauses.reduce((sum, pause) => sum + + new Date(pause.end.effectiveTime).getTime() - new Date(pause.start.effectiveTime).getTime(), 0) + + (block.runningPause ? now - new Date(block.runningPause.effectiveTime) : 0); + const formatDuration = ms => { + const minutes = Math.floor(Math.max(0, ms) / 60000); + return `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`; + }; + workBlock.totalWorkTime = formatDuration(elapsedMs); + workBlock.netWorkTime = formatDuration(elapsedMs - pauseMs); + // Füge laufenden Block zum Tag hinzu day.workBlocks.push(workBlock); @@ -1822,12 +1817,7 @@ class TimeEntryService { }; 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; + // Tatsächlich gebuchte Arbeit bleibt zusätzlich sichtbar. } // Prüfe auf Feiertag @@ -1886,11 +1876,11 @@ class TimeEntryService { } }); - // Berechne die reguläre Arbeitszeit ohne arbeitsfreie Tage. Der - // Urlaubs-/Krankheits-/Feiertagsanteil wird weiter unten separat ergänzt. + // Tatsächliche Arbeitszeit einschließlich laufender Blöcke. + // Abwesenheitsgutschriften werden separat ergänzt. let workMinutes = 0; Object.values(dayData).forEach(day => { - if (day.netWorkTime && !day.vacation && !day.sick && !day.holiday) { + if (day.netWorkTime) { const [hours, minutes] = day.netWorkTime.split(':').map(Number); workMinutes += hours * 60 + minutes; } @@ -1902,7 +1892,7 @@ class TimeEntryService { const nonWorkingDetails = []; Object.values(dayData).forEach(day => { - if (day.vacation && day.vacation.hours > 0) { + if (!day.sick && !day.holiday && day.vacation && day.vacation.hours > 0) { nonWorkingMinutes += day.vacation.hours * 60; nonWorkingDays++; nonWorkingDetails.push({ @@ -1920,7 +1910,7 @@ class TimeEntryService { hours: day.sick.hours }); } - if (day.holiday && day.holiday.hours > 0) { + if (!day.sick && day.holiday && day.holiday.hours > 0) { nonWorkingMinutes += day.holiday.hours * 60; nonWorkingDays++; nonWorkingDetails.push({ @@ -1935,10 +1925,8 @@ class TimeEntryService { const nonWorkingMins = nonWorkingMinutes % 60; const nonWorkingTotal = `${nonWorkingHours}:${nonWorkingMins.toString().padStart(2, '0')}`; - // Die Wochensumme enthält die reguläre Arbeitszeit und die Gutschrift für - // arbeitsfreie Tage. Die Gesamtsumme weist den Anteil arbeitsfreier Tage - // zusätzlich aus. - const weekTotalMinutes = workMinutes + nonWorkingMinutes; + // Wochensumme = tatsächliche Arbeit; Gesamtsumme = Arbeit + Gutschriften. + const weekTotalMinutes = workMinutes; const weekTotalHours = Math.floor(weekTotalMinutes / 60); const weekTotalMins = weekTotalMinutes % 60; const weekTotal = `${weekTotalHours}:${weekTotalMins.toString().padStart(2, '0')}`; @@ -1949,8 +1937,8 @@ class TimeEntryService { const totalAll = `${totalAllHours}:${totalAllMins.toString().padStart(2, '0')}`; return { - weekStart: weekStart.toISOString().split('T')[0], - weekEnd: weekEnd.toISOString().split('T')[0], + weekStart: `${weekStart.getFullYear()}-${String(weekStart.getMonth() + 1).padStart(2, '0')}-${String(weekStart.getDate()).padStart(2, '0')}`, + weekEnd: `${weekEnd.getFullYear()}-${String(weekEnd.getMonth() + 1).padStart(2, '0')}-${String(weekEnd.getDate()).padStart(2, '0')}`, weekOffset, days: Object.values(dayData).sort((a, b) => a.date.localeCompare(b.date)), weekTotal, diff --git a/backend/test/time-displays.test.js b/backend/test/time-displays.test.js new file mode 100644 index 0000000..3982aef --- /dev/null +++ b/backend/test/time-displays.test.js @@ -0,0 +1,103 @@ +process.env.TZ = 'Europe/Berlin'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const database = require('../src/config/database'); +const repository = require('../src/repositories/WorklogRepository'); +const service = require('../src/services/TimeEntryService'); +const RealDate = Date; + +function freezeTime(t, time = '2026-10-27T14:00:00') { + global.Date = class extends RealDate { + constructor(...args) { super(...(args.length ? args : [time])); } + static now() { return new RealDate(time).getTime(); } + }; + t.after(() => { global.Date = RealDate; }); +} + +function mock(t, object, key, value) { + const original = object[key]; + object[key] = value; + t.after(() => { object[key] = original; }); +} + +for (const [name, model, method] of [ + ['Krankheit', 'Sick', 'getSickByUserInDateRange'], + ['Urlaub', 'Vacation', 'getVacationsByUserInDateRange'] +]) { + test(`${name}: letzter Tag nach Zeitumstellung wird berücksichtigt`, async t => { + mock(t, database, 'getModels', () => ({ + [model]: { findAll: async () => [{ first_day: '2026-10-06', last_day: '2026-10-26', vacation_type: 2 }] } + })); + const days = await repository[method](1, new Date('2026-10-26T00:00:00'), new Date('2026-11-01T23:59:59')); + assert.deepEqual(days.map(day => day.date.toISOString().slice(0, 10)), ['2026-10-26']); + }); + test(`${name}: eintägige Abwesenheit an der Wochengrenze`, async t => { + mock(t, database, 'getModels', () => ({ + [model]: { findAll: async () => [{ first_day: '2026-10-26', last_day: '2026-10-26', vacation_type: 1 }] } + })); + const days = await repository[method](1, new Date('2026-10-26T00:00:00'), new Date('2026-11-01T23:59:59')); + assert.equal(days.length, 1); + assert.equal(days[0].date.toISOString().slice(0, 10), '2026-10-26'); + }); +} + +function setupWeek(t, { pause = false, sick = true, halfVacation = false } = {}) { + freezeTime(t); + const entries = [ + { id: 1, state: 'start work', tstamp: '2026-10-27 08:00:00' }, + { id: 2, state: 'start pause', tstamp: '2026-10-27 10:00:00', relatedTo_id: 1 }, + ...(!pause ? [{ id: 3, state: 'stop pause', tstamp: '2026-10-27 10:30:00', relatedTo_id: 2 }] : []) + ]; + mock(t, repository, 'findByDateRange', async () => entries); + mock(t, repository, 'findByUser', async () => entries); + mock(t, repository, 'getTimefixesByWorklogIds', async () => new Map()); + mock(t, repository, 'getSickByUserInDateRange', async () => sick ? [{ date: new Date('2026-10-26'), sick_type: 'self' }] : []); + mock(t, repository, 'getVacationsByUserInDateRange', async () => halfVacation ? [{ date: new Date('2026-10-27'), half_day: 1 }] : []); + mock(t, repository, 'getHolidaysInDateRange', async () => []); +} + +test('Wochenansicht: lokale Wochengrenzen, laufende Arbeit und Krankheit genau einmal', async t => { + setupWeek(t); + const week = await service.getWeekOverview(1); + assert.equal(week.weekStart, '2026-10-26'); + assert.equal(week.weekEnd, '2026-11-01'); + assert.equal(week.days[0].sick.hours, 8); + assert.equal(week.days[1].workBlocks[0].netWorkTime, '5:30'); + assert.equal(week.weekTotal, '5:30'); + assert.equal(week.nonWorkingTotal, '8:00'); + assert.equal(week.totalAll, '13:30'); +}); + +test('Wochenansicht: laufende Pause friert Nettozeit ein', async t => { + setupWeek(t, { pause: true }); + const week = await service.getWeekOverview(1); + assert.equal(week.days[1].workBlocks[0].netWorkTime, '2:00'); + assert.equal(week.weekTotal, '2:00'); +}); + +test('Wochenansicht: Arbeit am halben Urlaubstag geht nicht verloren', async t => { + setupWeek(t, { sick: false, halfVacation: true }); + const week = await service.getWeekOverview(1); + assert.equal(week.weekTotal, '5:30'); + assert.equal(week.nonWorkingTotal, '4:00'); + assert.equal(week.totalAll, '9:30'); +}); + +test('Status: keine Fehlzeit für kranken Montag, Pausen angerechnet, laufende Wochenzeit', async t => { + setupWeek(t); + mock(t, database, 'getModels', () => ({ + User: { findByPk: async () => ({ daily_hours: 8 }) }, + Timewish: { findOne: async () => null, findAll: async () => [] } + })); + mock(t, service, '_calculateTotalOvertime', async () => ({ minutes: 349, formatted: '+5:49' })); + const stats = await service.getStatistics(1); + assert.equal(stats.currentlyWorked, '05:30:00'); + assert.equal(stats.overtime, '+0:00'); + assert.equal(stats.weekWorktime, '5:30'); + assert.equal(stats.nonWorkingHours, '8:00'); + assert.equal(stats.missingBreakMinutes, 0); + assert.equal(stats.open, '02:30:00'); + assert.equal(stats.openForWeek, '2:30'); + assert.equal(stats.adjustedEndTodayWeek, '16:30'); + assert.equal(stats.totalOvertime, '+5:49'); +}); diff --git a/frontend/src/components/StatusBox.vue b/frontend/src/components/StatusBox.vue index 84dd673..61c1f24 100755 --- a/frontend/src/components/StatusBox.vue +++ b/frontend/src/components/StatusBox.vue @@ -88,6 +88,8 @@ const fetchStats = async () => { missingBreakMinutes.value = 0 } + updateCurrentlyWorkedTime() + updateOpenTime() isReady.value = true } @@ -164,7 +166,7 @@ const updateCurrentlyWorkedTime = () => { } // Verwende Server-Werte nur, wenn NICHT gearbeitet wird (oder kein laufender Start vorhanden) - const shouldUseServerSummary = !workStartTime.value + const shouldUseServerSummary = Boolean(serverWorkedTime.value) if (shouldUseServerSummary && serverWorkedTime.value && serverTimestamp.value) { // Parse Server-Zeit (HH:MM:SS) @@ -406,11 +408,9 @@ onBeforeUnmount(() => { }) const displayRows = computed(() => { - // Verwende Server-Wert für "Derzeit gearbeitet" wenn verfügbar (zeigt gesamte Tagesarbeitszeit) - const workedDisplay = (stats.value?.currentlyWorked && stats.value.currentlyWorked !== '—') - ? stats.value.currentlyWorked + ' h' - : (currentlyWorkedTime.value === '—' ? currentlyWorkedTime.value : currentlyWorkedTime.value + ' h'); - + const workedDisplay = currentlyWorkedTime.value === '—' + ? '—' : currentlyWorkedTime.value + ' h'; + const rows = { 'Derzeit gearbeitet': workedDisplay, 'Offen': openTime.value === '—' || openTime.value.includes('erreicht') ? openTime.value : openTime.value + ' h', diff --git a/frontend/src/views/Sick.vue b/frontend/src/views/Sick.vue index 62508d4..bc69b6e 100755 --- a/frontend/src/views/Sick.vue +++ b/frontend/src/views/Sick.vue @@ -196,6 +196,7 @@ async function createSick() { // Liste neu laden await loadSickEntries() + window.dispatchEvent(new CustomEvent('worklog-updated')) } catch (error) { console.error('Fehler beim Erstellen des Krankheitseintrags:', error) await alert(`Fehler: ${error.message}`, 'Fehler') @@ -227,6 +228,7 @@ async function deleteSick(id) { } await loadSickEntries() + window.dispatchEvent(new CustomEvent('worklog-updated')) } catch (error) { console.error('Fehler beim Löschen des Krankheitseintrags:', error) await alert(`Fehler: ${error.message}`, 'Fehler') diff --git a/frontend/src/views/Vacation.vue b/frontend/src/views/Vacation.vue index bac471c..1e9af74 100755 --- a/frontend/src/views/Vacation.vue +++ b/frontend/src/views/Vacation.vue @@ -181,6 +181,7 @@ async function createVacation() { resetForm() await loadVacations() + window.dispatchEvent(new CustomEvent('worklog-updated')) } catch (error) { console.error('Fehler beim Erstellen des Urlaubseintrags:', error) await alert(`Fehler: ${error.message}`, 'Fehler') @@ -210,6 +211,7 @@ async function deleteVacation(id) { } await loadVacations() + window.dispatchEvent(new CustomEvent('worklog-updated')) } catch (error) { console.error('Fehler beim Löschen des Urlaubseintrags:', error) await alert(`Fehler: ${error.message}`, 'Fehler') diff --git a/frontend/src/views/WeekOverview.vue b/frontend/src/views/WeekOverview.vue index 3980337..8539730 100755 --- a/frontend/src/views/WeekOverview.vue +++ b/frontend/src/views/WeekOverview.vue @@ -103,8 +103,7 @@
{{ block.totalWorkTime }}
- + @@ -204,6 +203,7 @@ const authStore = useAuthStore() const loading = ref(true) const weekData = ref(null) const weekOffset = ref(0) +let refreshInterval = null const weekDays = computed(() => { if (!weekData.value) return [] @@ -237,9 +237,9 @@ const formatDate = (dateString) => { }) } -const loadWeekData = async () => { +const loadWeekData = async (background = false) => { try { - loading.value = true + if (!background) loading.value = true const response = await fetch(`${API_URL}/week-overview?weekOffset=${weekOffset.value}`, { cache: 'no-store', @@ -381,12 +381,14 @@ const handleWorklogUpdate = () => { onMounted(() => { loadWeekData() + refreshInterval = setInterval(() => loadWeekData(true), 60000) // Event-Listener für Worklog-Updates window.addEventListener('worklog-updated', handleWorklogUpdate) }) onBeforeUnmount(() => { + if (refreshInterval) clearInterval(refreshInterval) window.removeEventListener('worklog-updated', handleWorklogUpdate) })