diff --git a/backend/services/trainingStatsService.js b/backend/services/trainingStatsService.js index 24a6cc9e..91dc946d 100644 --- a/backend/services/trainingStatsService.js +++ b/backend/services/trainingStatsService.js @@ -1,4 +1,4 @@ -import { DiaryDate, Member, Participant } from '../models/index.js'; +import { DiaryDate, Member, Participant, TrainingGroup } from '../models/index.js'; import { Op } from 'sequelize'; function getIsoWeekKey(dateLike) { @@ -36,6 +36,36 @@ function countMissedTrainingWeeks(trainingDates, lastTrainingDate) { return weeks.size; } +function getMonthKey(dateLike) { + const date = new Date(dateLike); + if (Number.isNaN(date.getTime())) { + return null; + } + + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`; +} + +function buildLastMonthsTemplate(monthCount = 12) { + const months = []; + const cursor = new Date(); + cursor.setDate(1); + + for (let i = monthCount - 1; i >= 0; i -= 1) { + const monthDate = new Date(cursor.getFullYear(), cursor.getMonth() - i, 1); + months.push({ + key: getMonthKey(monthDate), + year: monthDate.getFullYear(), + month: monthDate.getMonth(), + label: `${String(monthDate.getMonth() + 1).padStart(2, '0')}.${monthDate.getFullYear()}`, + trainingCount: 0, + participantCount: 0, + averageParticipants: 0, + }); + } + + return months; +} + class TrainingStatsService { async getTrainingStats(clubIdRaw) { const clubId = parseInt(clubIdRaw, 10); @@ -50,7 +80,14 @@ class TrainingStatsService { const threeMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate()); const members = await Member.findAll({ - where: { active: true, clubId } + where: { active: true, clubId }, + include: [{ + model: TrainingGroup, + as: 'trainingGroups', + attributes: ['id', 'name'], + through: { attributes: [] }, + required: false + }] }); const trainingsCount12Months = await DiaryDate.count({ @@ -88,7 +125,7 @@ class TrainingStatsService { date: { [Op.gte]: twelveMonthsAgo } } }], - where: { memberId: member.id } + where: { memberId: member.id, attendanceStatus: 'present' } }); const participation3Months = await Participant.count({ @@ -100,7 +137,7 @@ class TrainingStatsService { date: { [Op.gte]: threeMonthsAgo } } }], - where: { memberId: member.id } + where: { memberId: member.id, attendanceStatus: 'present' } }); const participationTotal = await Participant.count({ @@ -109,7 +146,7 @@ class TrainingStatsService { as: 'diaryDate', where: { clubId } }], - where: { memberId: member.id } + where: { memberId: member.id, attendanceStatus: 'present' } }); const trainingDetails = await Participant.findAll({ @@ -118,7 +155,7 @@ class TrainingStatsService { as: 'diaryDate', where: { clubId } }], - where: { memberId: member.id }, + where: { memberId: member.id, attendanceStatus: 'present' }, order: [['diaryDate', 'date', 'DESC']], limit: 50 }); @@ -141,9 +178,15 @@ class TrainingStatsService { firstName: member.firstName, lastName: member.lastName, birthDate: member.birthDate, + ttr: member.ttr ?? null, + qttr: member.qttr ?? null, + trainingGroups: Array.isArray(member.trainingGroups) + ? member.trainingGroups.map((group) => ({ id: group.id, name: group.name })) + : [], participation12Months, participation3Months, participationTotal, + participationRate12Months: trainingsCount12Months > 0 ? participation12Months / trainingsCount12Months : 0, lastTraining: lastTrainingDate, lastTrainingTs, missedTrainingWeeks, @@ -162,7 +205,7 @@ class TrainingStatsService { include: [{ model: Participant, as: 'participantList', - attributes: ['id'] + attributes: ['id', 'attendanceStatus'] }], order: [['date', 'DESC']] }); @@ -170,14 +213,85 @@ class TrainingStatsService { const formattedTrainingDays = trainingDays.map((day) => ({ id: day.id, date: day.date, - participantCount: day.participantList ? day.participantList.length : 0 + participantCount: day.participantList + ? day.participantList.filter((participant) => !participant.attendanceStatus || participant.attendanceStatus === 'present').length + : 0 })); + const totalParticipants12Months = formattedTrainingDays.reduce((sum, day) => sum + (day.participantCount || 0), 0); + const averageParticipants12Months = trainingsCount12Months > 0 ? totalParticipants12Months / trainingsCount12Months : 0; + const attendanceRate12Months = (members.length > 0 && trainingsCount12Months > 0) + ? (totalParticipants12Months / (members.length * trainingsCount12Months)) * 100 + : 0; + const inactiveMembersCount = stats.filter((member) => member.notInTraining).length; + const bestTrainingDay = formattedTrainingDays.reduce((best, day) => { + if (!best || (day.participantCount || 0) > (best.participantCount || 0)) { + return day; + } + return best; + }, null); + + const weekdayNames = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; + const weekdayBuckets = weekdayNames.map((name, index) => ({ + weekday: name, + weekdayIndex: index, + trainingCount: 0, + participantCount: 0, + averageParticipants: 0, + })); + + const monthlyTrend = buildLastMonthsTemplate(12); + const monthlyTrendMap = new Map(monthlyTrend.map((entry) => [entry.key, entry])); + + for (const day of formattedTrainingDays) { + const date = new Date(day.date); + if (Number.isNaN(date.getTime())) { + continue; + } + + const weekdayEntry = weekdayBuckets[date.getDay()]; + weekdayEntry.trainingCount += 1; + weekdayEntry.participantCount += day.participantCount || 0; + + const monthKey = getMonthKey(day.date); + if (monthKey && monthlyTrendMap.has(monthKey)) { + const monthEntry = monthlyTrendMap.get(monthKey); + monthEntry.trainingCount += 1; + monthEntry.participantCount += day.participantCount || 0; + } + } + + weekdayBuckets.forEach((entry) => { + entry.averageParticipants = entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0; + }); + + monthlyTrend.forEach((entry) => { + entry.averageParticipants = entry.trainingCount > 0 ? entry.participantCount / entry.trainingCount : 0; + }); + + const memberDistribution = { + highlyActive: stats.filter((member) => member.participationRate12Months >= 0.75).length, + regular: stats.filter((member) => member.participationRate12Months >= 0.4 && member.participationRate12Months < 0.75).length, + occasional: stats.filter((member) => member.participationRate12Months > 0 && member.participationRate12Months < 0.4).length, + inactive: stats.filter((member) => member.participation12Months === 0).length, + }; + return { members: stats, trainingsCount12Months, trainingsCount3Months, - trainingDays: formattedTrainingDays + trainingDays: formattedTrainingDays, + overview: { + activeMembersCount: members.length, + totalParticipants12Months, + averageParticipants12Months, + attendanceRate12Months, + inactiveMembersCount, + bestTrainingDay, + }, + weekdayStats: weekdayBuckets.filter((entry) => entry.trainingCount > 0), + monthlyTrend, + memberDistribution, }; } } diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index bb4ad66b..30d567ce 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -301,6 +301,8 @@ "editHint": "Ein Klick auf eine Zeile öffnet den Editor.", "editorCreateHint": "Neues Mitglied anlegen und Kontaktdaten direkt erfassen.", "editorEditHint": "Daten von {name} bearbeiten.", + "editorAssignTrainingGroupHint": "Bitte mindestens eine Trainingsgruppe zuordnen.", + "editorRecommendedEntry": "Empfohlener Eintrag", "transferSuccessTitle": "Übertragung erfolgreich", "transferErrorTitle": "Fehler bei der Übertragung", "clickTtRequestPending": "Click-TT-Antrag läuft", @@ -353,12 +355,14 @@ "dataIssueEmail": "E-Mail fehlt", "dataIssueAddress": "Adresse fehlt", "dataIssueGender": "Geschlecht ungeklärt", + "dataIssueTrainingGroup": "Trainingsgruppe fehlt", "openTasks": "Offene Aufgaben", "noOpenTasks": "Keine offenen Aufgaben", "taskVerifyForm": "Formular prüfen", "taskReviewTrialStatus": "Probe-Status prüfen", "taskCheckTrainingStatus": "Trainingsstatus prüfen", "taskCheckDataQuality": "Datenqualität prüfen", + "taskAssignTrainingGroup": "Trainingsgruppe zuordnen", "taskCheckClickTt": "Click-TT-Spielberechtigung prüfen", "taskActionVerify": "Prüfen", "taskActionMarkRegular": "Regulär setzen", diff --git a/frontend/src/views/MembersView.vue b/frontend/src/views/MembersView.vue index a56ed185..b1a67f75 100644 --- a/frontend/src/views/MembersView.vue +++ b/frontend/src/views/MembersView.vue @@ -107,9 +107,9 @@
{{ $t('members.trainingGroups') }} -
+
@@ -184,16 +184,33 @@
- - - - + + + + -
+
+
{{ $t('members.editorRecommendedEntry') }}
- + @@ -207,10 +224,11 @@
-
+
+
{{ $t('members.editorRecommendedEntry') }}
- + @@ -222,13 +240,14 @@
- @@ -236,7 +255,7 @@ -
+
{{ $t('members.noGroupsAssigned') }}
+
{{ $t('members.editorAssignTrainingGroupHint') }}