From ba5938419ab31145f6862ecb2c6f6da73788a121 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 27 Oct 2026 14:21:23 +0100 Subject: [PATCH] =?UTF-8?q?Verbessere=20die=20Handhabung=20von=20Datumsber?= =?UTF-8?q?eichen=20in=20der=20Worklog-Abfrage=20und=20f=C3=BCge=20Fehlerb?= =?UTF-8?q?ehandlung=20f=C3=BCr=20Statistikabfragen=20in=20den=20Komponent?= =?UTF-8?q?en=20hinzu.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/repositories/WorklogRepository.js | 4 +- backend/test/time-displays.test.js | 71 +++++++++ frontend/src/components/StatusBox.vue | 78 ++++++---- frontend/src/stores/timeStore.js | 7 +- frontend/src/views/WeekOverview.vue | 136 +++++------------- frontend/test/load-errors.test.cjs | 107 ++++++++++++++ package.json | 3 +- 7 files changed, 268 insertions(+), 138 deletions(-) create mode 100644 frontend/test/load-errors.test.cjs diff --git a/backend/src/repositories/WorklogRepository.js b/backend/src/repositories/WorklogRepository.js index 9c1fee6..d70976a 100755 --- a/backend/src/repositories/WorklogRepository.js +++ b/backend/src/repositories/WorklogRepository.js @@ -367,7 +367,7 @@ class WorklogRepository { where: { user_id: userId, tstamp: { - [Op.between]: [startKey, endKey] + [Op.between]: [startDate, endDate] } }, order: [['tstamp', 'ASC']], @@ -438,7 +438,7 @@ class WorklogRepository { where: { user_id: userId, tstamp: { - [Op.between]: [startKey, endKey] + [Op.between]: [startDate, endDate] } }, order: [['tstamp', 'ASC']] // Aufsteigend, damit Start vor Stop kommt diff --git a/backend/test/time-displays.test.js b/backend/test/time-displays.test.js index 3982aef..72dced2 100644 --- a/backend/test/time-displays.test.js +++ b/backend/test/time-displays.test.js @@ -101,3 +101,74 @@ test('Status: keine Fehlzeit für kranken Montag, Pausen angerechnet, laufende W assert.equal(stats.adjustedEndTodayWeek, '16:30'); assert.equal(stats.totalOvertime, '+5:49'); }); + +// Repository nicht ersetzen: Genau dort muss die Datumsabfrage funktionieren. +const { Op } = require('sequelize'); +const weekController = require('../src/controllers/WeekOverviewController'); +const timeController = require('../src/controllers/TimeEntryController'); +const actualFindByDateRange = repository.findByDateRange; + +function responseRecorder() { + return { + statusCode: 200, + status(code) { this.statusCode = code; return this; }, + json(body) { this.body = body; return this; } + }; +} + +function setupRepositoryFlow(t) { + setupWeek(t); + mock(t, repository, 'findByDateRange', actualFindByDateRange); + mock(t, database, 'getModels', () => ({ + Worklog: { + findAll: async options => { + assert.equal(options.where.user_id, 1); + const range = options.where.tstamp[Op.between]; + assert.equal(range[0].getTime(), new Date('2026-10-26T00:00:00').getTime()); + assert.equal(range[1].getTime(), new Date('2026-11-01T23:59:59.999').getTime()); + return [{ id: 1, state: 'start work', tstamp: '2026-10-27 08:00:00' }]; + } + }, + User: { findByPk: async () => ({ daily_hours: 8 }) }, + Timewish: { findOne: async () => null, findAll: async () => [] } + })); + mock(t, service, '_calculateTotalOvertime', async () => ({ minutes: 349, formatted: '+5:49' })); +} + +test('Wochen-API durchläuft echte Repository-Abfrage und liefert aktuelle Woche', async t => { + setupRepositoryFlow(t); + const res = responseRecorder(); + await weekController.getWeekOverview({ user: { userId: 1 }, query: { weekOffset: '0' } }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.data.weekStart, '2026-10-26'); + assert.equal(res.body.data.weekEnd, '2026-11-01'); + assert.equal(res.body.data.days[0].sick.hours, 8); +}); + +test('Statistik-API durchläuft echte Repository-Abfrage und liefert Werte statt Fehlerobjekt', async t => { + setupRepositoryFlow(t); + const res = responseRecorder(); + await timeController.getStats({ user: { userId: 1 } }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.overtime, '+0:00'); + assert.equal(res.body.nonWorkingHours, '8:00'); + assert.match(res.body.open, /^\d{2}:\d{2}:\d{2}$/); +}); + +test('Repository: Arbeitszeitpaare verwenden den vollständigen Datumsbereich', async t => { + const start = new Date('2026-10-26T00:00:00'); + const end = new Date('2026-11-01T23:59:59.999'); + let queried = false; + mock(t, database, 'getModels', () => ({ Worklog: { findAll: async options => { + queried = true; + assert.deepEqual(options.where.tstamp[Op.between], [start, end]); + return [ + { id: 1, state: 'start work', tstamp: '2026-10-27 08:00:00' }, + { id: 2, state: 'stop work', tstamp: '2026-10-27 16:00:00', relatedTo_id: 1 } + ]; + } } })); + const pairs = await repository.findPairsByUserInDateRange(1, start, end); + assert.equal(queried, true); + assert.equal(pairs.length, 1); + assert.equal(pairs[0].end_id, 2); +}); diff --git a/frontend/src/components/StatusBox.vue b/frontend/src/components/StatusBox.vue index 61c1f24..afab5da 100755 --- a/frontend/src/components/StatusBox.vue +++ b/frontend/src/components/StatusBox.vue @@ -23,6 +23,10 @@ +
{{ key }}{{ value === null ? '' : ':' }} @@ -44,6 +48,7 @@ const API_URL = API_BASE_URL const timeStore = useTimeStore() const authStore = useAuthStore() const stats = ref({}) +const statsError = ref('') const currentState = ref(null) // 'null', 'start work', 'start pause', 'stop pause' const isReady = ref(false) const loading = ref(false) @@ -61,36 +66,43 @@ let dataFetchInterval = null // Daten vom Server laden let displayUpdateInterval = null // Anzeige aktualisieren const fetchStats = async () => { - const data = await timeStore.fetchStats() - stats.value = data || {} + try { + const data = await timeStore.fetchStats() + statsError.value = '' + stats.value = data || {} - // Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp - if (data?.currentlyWorked) { - serverWorkedTime.value = data.currentlyWorked - } else { - serverWorkedTime.value = null + // Hole vom Server berechnete Arbeitszeit, offene Zeit und Timestamp + if (data?.currentlyWorked) { + serverWorkedTime.value = data.currentlyWorked + } else { + serverWorkedTime.value = null + } + + if (data?.open) { + serverOpenTime.value = data.open + } else { + serverOpenTime.value = null + } + + if (data?.timestamp) { + serverTimestamp.value = new Date(data.timestamp).getTime() + } + + // Hole fehlende Pausenminuten + if (data?.missingBreakMinutes !== undefined) { + missingBreakMinutes.value = data.missingBreakMinutes + } else { + missingBreakMinutes.value = 0 + } + + updateCurrentlyWorkedTime() + updateOpenTime() + } catch (error) { + statsError.value = 'Statistiken konnten nicht geladen werden.' + + (serverTimestamp.value ? ' Angezeigte Werte können veraltet sein.' : '') + } finally { + isReady.value = true } - - if (data?.open) { - serverOpenTime.value = data.open - } else { - serverOpenTime.value = null - } - - if (data?.timestamp) { - serverTimestamp.value = new Date(data.timestamp).getTime() - } - - // Hole fehlende Pausenminuten - if (data?.missingBreakMinutes !== undefined) { - missingBreakMinutes.value = data.missingBreakMinutes - } else { - missingBreakMinutes.value = 0 - } - - updateCurrentlyWorkedTime() - updateOpenTime() - isReady.value = true } /** Lädt Zustand, laufende Zeiten und Tages-Stats neu (z. B. nach Stempeln oder Zeitkorrektur). */ @@ -419,8 +431,8 @@ const displayRows = computed(() => { // Füge andere Stats hinzu const map = [ - ['overtime', 'overtime'], // Label wird dynamisch gesetzt - ['totalOvertime', 'totalOvertime'], // Label wird dynamisch gesetzt + ['Überstunden (Woche)', 'overtime'], // Label wird dynamisch gesetzt + ['Überstunden (Gesamt)', 'totalOvertime'], // Label wird dynamisch gesetzt ['Wochenarbeitszeit', 'weekWorktime'], ['Arbeitsfreie Stunden', 'nonWorkingHours'], ['Offen für Woche', 'openForWeek'], @@ -554,6 +566,12 @@ const displayRows = computed(() => { padding: 6px 12px; } +.stats-error { + color: #842029; + padding: 8px 0; + font-size: 12px; +} + .status-grid { font-size: 12px; column-gap: 16px; diff --git a/frontend/src/stores/timeStore.js b/frontend/src/stores/timeStore.js index fd3b4ec..bd751bd 100755 --- a/frontend/src/stores/timeStore.js +++ b/frontend/src/stores/timeStore.js @@ -106,10 +106,13 @@ export const useTimeStore = defineStore('time', () => { const fetchStats = async () => { try { const response = await fetchWithAuth(`${API_URL}/time-entries/stats/summary`) - return await response.json() + if (!response.ok) throw new Error('Statistiken konnten nicht geladen werden') + const data = await response.json() + if (data.error) throw new Error(data.error) + return data } catch (error) { console.error('Fehler beim Laden der Statistiken:', error) - return {} + throw error } } diff --git a/frontend/src/views/WeekOverview.vue b/frontend/src/views/WeekOverview.vue index 8539730..b495967 100755 --- a/frontend/src/views/WeekOverview.vue +++ b/frontend/src/views/WeekOverview.vue @@ -13,7 +13,12 @@
- + + +
@@ -202,6 +207,8 @@ const authStore = useAuthStore() const loading = ref(true) const weekData = ref(null) +const loadError = ref('') +let latestRequest = 0 const weekOffset = ref(0) let refreshInterval = null @@ -216,8 +223,14 @@ const weekDays = computed(() => { }) const weekRange = computed(() => { - if (!weekData.value) return '' - + if (!weekData.value) { + const monday = new Date(); + monday.setDate(monday.getDate() + (monday.getDay() === 0 ? -6 : 1 - monday.getDay()) + weekOffset.value * 7); + const sunday = new Date(monday); + sunday.setDate(sunday.getDate() + 6); + return `${formatDate(monday)} - ${formatDate(sunday)}`; + } + const start = formatDate(weekData.value.weekStart) const end = formatDate(weekData.value.weekEnd) @@ -238,10 +251,12 @@ const formatDate = (dateString) => { } const loadWeekData = async (background = false) => { + const requestId = ++latestRequest + const requestedOffset = weekOffset.value try { if (!background) loading.value = true - const response = await fetch(`${API_URL}/week-overview?weekOffset=${weekOffset.value}`, { + const response = await fetch(`${API_URL}/week-overview?weekOffset=${requestedOffset}`, { cache: 'no-store', headers: { 'Authorization': `Bearer ${authStore.token}`, @@ -255,17 +270,20 @@ const loadWeekData = async (background = false) => { const result = await response.json() - if (result.success) { + if (requestId !== latestRequest) return + if (result.success && result.data?.weekStart && Array.isArray(result.data.days)) { weekData.value = result.data + loadError.value = '' } else { throw new Error(result.error || 'Unbekannter Fehler') } } catch (error) { console.error('Fehler beim Laden der Wochenübersicht:', error) - // Fallback zu Dummy-Daten bei Fehlern - weekData.value = getDummyData() + if (requestId !== latestRequest) return + weekData.value = null + loadError.value = 'Die Wochenübersicht konnte nicht geladen werden.' } finally { - loading.value = false + if (requestId === latestRequest) loading.value = false } } @@ -279,101 +297,6 @@ const nextWeek = () => { loadWeekData() } -const getDummyData = () => { - return { - weekStart: '2025-01-13', - weekEnd: '2025-01-19', - weekOffset: weekOffset.value, - weekTotal: '40:00', - days: [ - { - date: '2025-01-13', - name: 'Montag', - workTime: '08:00 - 16:30', - pauses: ['12:00 - 12:30'], - totalWorkTime: '8:30', - pauseTimes: ['0:30'], - netWorkTime: '8:00', - status: 'complete', - statusText: 'Abgeschlossen', - isToday: false - }, - { - date: '2025-01-14', - name: 'Dienstag', - workTime: '08:15 - 17:00', - pauses: ['12:00 - 12:30', '14:00 - 14:30'], - totalWorkTime: '8:45', - pauseTimes: ['0:30', '0:30'], - netWorkTime: '7:45', - status: 'complete', - statusText: 'Abgeschlossen', - isToday: false - }, - { - date: '2025-01-15', - name: 'Mittwoch', - workTime: '08:00 - 16:00', - pauses: ['12:00 - 12:30'], - totalWorkTime: '8:00', - pauseTimes: ['0:30'], - netWorkTime: '7:30', - status: 'complete', - statusText: 'Abgeschlossen', - isToday: false - }, - { - date: '2025-01-16', - name: 'Donnerstag', - workTime: '08:30 - 17:30', - pauses: ['12:00 - 12:30', '15:00 - 15:15'], - totalWorkTime: '9:00', - pauseTimes: ['0:30', '0:15'], - netWorkTime: '8:15', - status: 'complete', - statusText: 'Abgeschlossen', - isToday: false - }, - { - date: '2025-01-17', - name: 'Freitag', - workTime: '08:00 - 15:00', - pauses: ['12:00 - 12:30'], - totalWorkTime: '7:00', - pauseTimes: ['0:30'], - netWorkTime: '6:30', - status: 'complete', - statusText: 'Abgeschlossen', - isToday: false - }, - { - date: '2025-01-18', - name: 'Samstag', - workTime: null, - pauses: [], - totalWorkTime: null, - pauseTimes: [], - netWorkTime: null, - status: 'weekend', - statusText: 'Wochenende', - isToday: false - }, - { - date: '2025-01-19', - name: 'Sonntag', - workTime: null, - pauses: [], - totalWorkTime: null, - pauseTimes: [], - netWorkTime: null, - status: 'weekend', - statusText: 'Wochenende', - isToday: false - } - ] - } -} - const handleWorklogUpdate = () => { console.log('Worklog aktualisiert, lade Wochendaten neu...') loadWeekData() @@ -418,6 +341,13 @@ onBeforeUnmount(() => { font-size: 0.9rem; } +.load-error { + padding: 1rem; + color: #842029; + background: #f8d7da; + border-radius: 6px; +} + .loading { text-align: center; padding: 2rem; diff --git a/frontend/test/load-errors.test.cjs b/frontend/test/load-errors.test.cjs new file mode 100644 index 0000000..f40c821 --- /dev/null +++ b/frontend/test/load-errors.test.cjs @@ -0,0 +1,107 @@ +process.env.TZ = 'Europe/Berlin'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const { parse, compileScript } = require('@vue/compiler-sfc'); +const { transformSync } = require('esbuild'); +const vue = require('vue'); + +// Execute the real Vue setup and store code, with HTTP responses controlled by +// each test. No mounted app or browser globals are needed for these load paths. +function load(relativePath, dependencies = {}) { + const filename = path.resolve(__dirname, '..', relativePath); + let source = readFileSync(filename, 'utf8'); + if (filename.endsWith('.vue')) { + source = compileScript(parse(source).descriptor, { id: 'load-error-test' }).content; + } + const code = transformSync(source, { format: 'cjs', loader: 'js' }).code; + const module = { exports: {} }; + vm.runInThisContext(`(function(require, module, exports) { ${code}\n})`, { filename })(id => { + if (id in dependencies) return dependencies[id]; + if (id === 'vue') return { ...vue, onMounted() {}, onBeforeUnmount() {} }; + if (id === 'pinia') return { defineStore: (_name, setup) => setup }; + if (id === '@/config/api') return { API_BASE_URL: '/api' }; + if (id.endsWith('authStore')) return { + useAuthStore: () => ({ token: 'test-token', getAuthHeaders: () => ({}), clearAuth() {} }) + }; + throw new Error(`Unexpected import: ${id}`); + }, module, module.exports); + return module.exports; +} + +function component(file, dependencies) { + return load(file, dependencies).default.setup({}, { expose() {} }); +} + +const ok = data => ({ ok: true, json: async () => data }); + +test('Wochenansicht fordert beim Öffnen aktuelle Woche an', async t => { + let requestedUrl; + t.mock.method(global, 'fetch', async url => { + requestedUrl = url; + return ok({ success: true, data: { weekStart: '2026-10-26', weekEnd: '2026-11-01', days: [] } }); + }); + const page = component('src/views/WeekOverview.vue'); + await page.loadWeekData(); + assert.equal(requestedUrl, '/api/week-overview?weekOffset=0'); + assert.equal(page.weekRange.value, '26.10.2026 - 01.11.2026'); + assert.equal(page.loadError.value, ''); +}); + +test('Wochenansicht zeigt bei HTTP 500 Fehler und aktuelle Wochengrenzen statt Beispieldaten', async t => { + const RealDate = Date; + t.mock.method(global, 'Date', class extends RealDate { + constructor(...args) { super(...(args.length ? args : ['2026-10-27T14:00:00'])); } + }); + t.mock.method(console, 'error', () => {}); + t.mock.method(global, 'fetch', async () => ({ ok: false, status: 500 })); + const page = component('src/views/WeekOverview.vue'); + await page.loadWeekData(); + assert.equal(page.weekData.value, null); + assert.deepEqual(page.weekDays.value, []); + assert.equal(page.weekRange.value, '26.10.2026 - 01.11.2026'); + assert.match(page.loadError.value, /nicht geladen/); + assert.equal(page.loading.value, false); +}); + +test('Ältere Wochenantwort überschreibt die zuletzt ausgewählte Woche nicht', async t => { + let finishOldRequest; + t.mock.method(global, 'fetch', url => url.endsWith('=0') + ? new Promise(resolve => { finishOldRequest = resolve; }) + : Promise.resolve(ok({ success: true, data: { weekStart: '2026-11-02', weekEnd: '2026-11-08', days: [] } }))); + const page = component('src/views/WeekOverview.vue'); + const oldRequest = page.loadWeekData(); + page.weekOffset.value = 1; + await page.loadWeekData(); + finishOldRequest(ok({ success: true, data: { weekStart: '2026-10-26', weekEnd: '2026-11-01', days: [] } })); + await oldRequest; + assert.equal(page.weekRange.value, '02.11.2026 - 08.11.2026'); +}); + +test('Statistikfehler wird angezeigt, gültige Werte bleiben erhalten und Wiederholen funktioniert', async t => { + t.mock.method(console, 'error', () => {}); + const stats = { + timestamp: new Date().toISOString(), currentlyWorked: '03:53:53', open: '03:06:07', + overtime: '+0:00', totalOvertime: '+5:49', weekWorktime: '3:53', nonWorkingHours: '8:00' + }; + let failing = true; + t.mock.method(global, 'fetch', async () => failing ? { ok: false, status: 500 } : ok(stats)); + const store = load('src/stores/timeStore.js'); + const box = component('src/components/StatusBox.vue', { '../stores/timeStore': store }); + await box.fetchStats(); + assert.match(box.statsError.value, /nicht geladen/); + assert.equal(box.isReady.value, true); + assert.equal(box.displayRows.value['Überstunden (Woche)'], '—'); + assert.equal('overtime' in box.displayRows.value, false); + assert.equal('totalOvertime' in box.displayRows.value, false); + failing = false; + await box.fetchStats(); + assert.equal(box.statsError.value, ''); + assert.equal(box.displayRows.value['Überstunden (Gesamt)'], '+5:49 h'); + failing = true; + await box.fetchStats(); + assert.match(box.statsError.value, /veraltet/); + assert.equal(box.displayRows.value['Überstunden (Gesamt)'], '+5:49 h'); +}); diff --git a/package.json b/package.json index 6579eba..ef49662 100755 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dev:frontend": "cd frontend && npm run dev", "dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"", "start:backend": "cd backend && npm start", - "build:frontend": "cd frontend && npm run build" + "build:frontend": "cd frontend && npm run build", + "test": "node --test backend/test/*.test.js frontend/test/*.test.cjs" }, "keywords": [ "timeclock",
Tag