feat(TrainingStatsService, MembersView, TrainingStatsView): enhance training statistics and member management features

- Added new functions in TrainingStatsService to calculate monthly trends and member distribution based on training participation.
- Updated MembersView to improve the display of training groups and address potential data entry issues with visual hints.
- Enhanced TrainingStatsView with new filters for weekdays and training groups, and improved the layout for displaying training statistics, including average participation and attendance rates.
- Introduced additional statistics panels for better insights into training performance and member engagement.
This commit is contained in:
Torsten Schulz (local)
2026-03-18 21:07:52 +01:00
parent dc15b48b80
commit b13d33c72c
4 changed files with 861 additions and 149 deletions

View File

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