Berücksichtige lokale Datumswerte bei der Berechnung von Abwesenheiten und verbessere die Testabdeckung für Wochenansichten und Arbeitszeiten.
All checks were successful
Deploy production / deploy (push) Successful in 53s

This commit is contained in:
Torsten Schulz (local)
2026-10-27 14:05:55 +01:00
parent 473ab93eb7
commit df17d37c16
7 changed files with 187 additions and 81 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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');
});

View File

@@ -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,10 +408,8 @@ 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,

View File

@@ -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')

View File

@@ -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')

View File

@@ -103,8 +103,7 @@
<div v-if="block.totalWorkTime" class="total-work-time">
{{ block.totalWorkTime }}
</div>
<!-- Laufende Blöcke haben keine Gesamtarbeitszeit. Der Platzhalter hält
die anschließenden Pausensummen auf Höhe der Pausenzeit. -->
<!-- Platzhalter für ältere API-Antworten ohne laufende Dauer. -->
<div v-else-if="block.pauseTimes && block.pauseTimes.length > 0" class="total-work-time" aria-hidden="true">
&nbsp;
</div>
@@ -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)
})
</script>