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:
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user