feat(TournamentStats): add internal tournament statistics endpoint and localization updates
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
- Implemented a new endpoint `getInternalTournamentStats` in the tournament controller to retrieve statistics for internal tournaments based on club ID and selected months. - Enhanced the tournament service to compute player statistics, including absolute and average rankings. - Updated tournament routes to include the new statistics endpoint. - Added localization strings for internal tournament statistics in multiple languages, improving user accessibility and experience. - Integrated the new statistics component into the TournamentsView for better user interaction.
This commit is contained in:
210
backend/services/internalTournamentStatsService.js
Normal file
210
backend/services/internalTournamentStatsService.js
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Statistik-Punkte für interne Einzel-Turniere (Gruppenphase + K.-o.-Runde).
|
||||
*
|
||||
* Gruppe: Letzter in der Gruppe = 1 Punkt, Vorletzter = 2, … Gleiche Platzierung = gleiche Punkte.
|
||||
* K.-o.: Wer die K.-o.-Runde erreicht: höchste Gruppenpunkte dieser Klasse + 1,
|
||||
* danach +1 pro gewonnenes K.-o.-Spiel.
|
||||
*/
|
||||
|
||||
export function parseWinnerFromMatch(match) {
|
||||
if (!match || !match.isFinished) return { winnerId: null, loserId: null };
|
||||
if (String(match.result || '').toUpperCase() === 'BYE') {
|
||||
const wid = match.player1Id || match.player2Id;
|
||||
const lid = wid === match.player1Id ? match.player2Id : match.player1Id;
|
||||
return { winnerId: wid || null, loserId: lid || null };
|
||||
}
|
||||
const results = match.tournamentResults;
|
||||
if (Array.isArray(results) && results.length > 0) {
|
||||
let w1 = 0;
|
||||
let w2 = 0;
|
||||
for (const r of results) {
|
||||
const p1 = Number(r.pointsPlayer1);
|
||||
const p2 = Number(r.pointsPlayer2);
|
||||
if (Number.isFinite(p1) && Number.isFinite(p2)) {
|
||||
if (p1 > p2) w1 += 1;
|
||||
else if (p2 > p1) w2 += 1;
|
||||
}
|
||||
}
|
||||
if (w1 !== w2) {
|
||||
return w1 > w2
|
||||
? { winnerId: match.player1Id, loserId: match.player2Id }
|
||||
: { winnerId: match.player2Id, loserId: match.player1Id };
|
||||
}
|
||||
}
|
||||
if (typeof match.result === 'string') {
|
||||
const tokens = match.result.match(/-?\d+\s*:\s*-?\d+/g);
|
||||
if (tokens && tokens.length > 0) {
|
||||
const last = tokens[tokens.length - 1];
|
||||
const parts = last.split(':');
|
||||
const a = Number((parts[0] || '').trim());
|
||||
const b = Number((parts[1] || '').trim());
|
||||
if (Number.isFinite(a) && Number.isFinite(b) && a !== b) {
|
||||
return a > b
|
||||
? { winnerId: match.player1Id, loserId: match.player2Id }
|
||||
: { winnerId: match.player2Id, loserId: match.player1Id };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { winnerId: null, loserId: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ position: number, id: number }>} rankings – Platz 1 = bestes Ergebnis; id = Turnier-Mitglied-ID
|
||||
* @returns {Map<number, number>} tournamentMemberId -> Punkte (von unten gezählt)
|
||||
*/
|
||||
export function groupPointsFromRankings(rankings) {
|
||||
const map = new Map();
|
||||
if (!rankings || rankings.length === 0) return map;
|
||||
const sorted = [...rankings].sort((a, b) => Number(b.position) - Number(a.position));
|
||||
let pts = 1;
|
||||
let i = 0;
|
||||
while (i < sorted.length) {
|
||||
const pos = Number(sorted[i].position);
|
||||
let j = i;
|
||||
while (j < sorted.length && Number(sorted[j].position) === pos) {
|
||||
const id = sorted[j].id;
|
||||
if (id != null) map.set(Number(id), pts);
|
||||
j += 1;
|
||||
}
|
||||
i = j;
|
||||
pts += 1;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.groups – Aus getGroupsWithParticipants (participants: flache Objekte mit id, position, isExternal, …)
|
||||
* @param {Array} opts.matches – Aus getTournamentMatches
|
||||
* @param {Array<{ id: number, isDoubles?: boolean }>} opts.classes
|
||||
* @param {Map<number, number>} opts.tmToMemberId – Turnier-Mitglied-ID -> Vereins-Mitglied-ID
|
||||
* @param {Map<number, { firstName: string, lastName: string }>} [opts.tmToName] – optional Namen
|
||||
* @returns {Map<number, { points: number, firstName: string, lastName: string }>} Vereins-Mitglied-ID -> Aggregation
|
||||
*/
|
||||
export function computeInternalSinglesStatsForTournament({
|
||||
groups,
|
||||
matches,
|
||||
classes,
|
||||
tmToMemberId,
|
||||
tmToName,
|
||||
}) {
|
||||
const doublesClassIds = new Set(
|
||||
(classes || []).filter((c) => c.isDoubles).map((c) => Number(c.id))
|
||||
);
|
||||
|
||||
const classKeys = new Set();
|
||||
for (const g of groups || []) {
|
||||
const cid = g.classId != null ? Number(g.classId) : null;
|
||||
if (cid != null && doublesClassIds.has(cid)) continue;
|
||||
classKeys.add(cid != null ? String(cid) : 'null');
|
||||
}
|
||||
for (const m of matches || []) {
|
||||
if (m.round === 'group') continue;
|
||||
const cid = m.classId != null ? Number(m.classId) : null;
|
||||
if (cid != null && doublesClassIds.has(cid)) continue;
|
||||
classKeys.add(cid != null ? String(cid) : 'null');
|
||||
}
|
||||
|
||||
const memberTotals = new Map();
|
||||
|
||||
const ensureMember = (memberId, firstName, lastName) => {
|
||||
if (memberId == null || !Number.isFinite(memberId)) return null;
|
||||
if (!memberTotals.has(memberId)) {
|
||||
memberTotals.set(memberId, {
|
||||
points: 0,
|
||||
firstName: firstName || '',
|
||||
lastName: lastName || '',
|
||||
});
|
||||
}
|
||||
return memberTotals.get(memberId);
|
||||
};
|
||||
|
||||
const nameForTm = (tmId) => {
|
||||
const n = tmToName?.get(Number(tmId));
|
||||
return { firstName: n?.firstName || '', lastName: n?.lastName || '' };
|
||||
};
|
||||
|
||||
for (const classKey of classKeys) {
|
||||
const classIdNum = classKey === 'null' ? null : Number(classKey);
|
||||
if (classIdNum != null && doublesClassIds.has(classIdNum)) continue;
|
||||
|
||||
const classGroups = (groups || []).filter((g) => {
|
||||
const gc = g.classId != null ? Number(g.classId) : null;
|
||||
const key = gc != null ? String(gc) : 'null';
|
||||
return key === classKey;
|
||||
});
|
||||
|
||||
const groupPointsByTm = new Map();
|
||||
let maxGroupPoints = 0;
|
||||
|
||||
for (const g of classGroups) {
|
||||
const parts = g.participants || [];
|
||||
if (parts.length === 0) continue;
|
||||
|
||||
const singlesRankings = parts
|
||||
.filter((p) => !p.isExternal && p.id && tmToMemberId.has(Number(p.id)))
|
||||
.map((p) => ({
|
||||
id: Number(p.id),
|
||||
position: Number(p.position || 0),
|
||||
}))
|
||||
.filter((r) => r.position > 0);
|
||||
|
||||
const m = groupPointsFromRankings(singlesRankings);
|
||||
for (const [tmId, pts] of m) {
|
||||
groupPointsByTm.set(tmId, pts);
|
||||
if (pts > maxGroupPoints) maxGroupPoints = pts;
|
||||
}
|
||||
}
|
||||
|
||||
const koMatches = (matches || []).filter((m) => {
|
||||
if (m.round === 'group') return false;
|
||||
const mid = m.classId != null ? Number(m.classId) : null;
|
||||
if (classIdNum == null) return mid == null;
|
||||
return mid === classIdNum;
|
||||
});
|
||||
|
||||
const koWins = new Map();
|
||||
const playedKo = new Set();
|
||||
|
||||
for (const m of koMatches) {
|
||||
if (!m.isFinished) continue;
|
||||
const p1 = m.player1Id;
|
||||
const p2 = m.player2Id;
|
||||
if (p1) playedKo.add(p1);
|
||||
if (p2) playedKo.add(p2);
|
||||
const { winnerId } = parseWinnerFromMatch(m);
|
||||
if (winnerId) {
|
||||
koWins.set(winnerId, (koWins.get(winnerId) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const tmIdsInClass = new Set([...groupPointsByTm.keys(), ...playedKo]);
|
||||
|
||||
for (const tmId of tmIdsInClass) {
|
||||
const mid = tmToMemberId.get(Number(tmId));
|
||||
if (mid == null) continue;
|
||||
|
||||
const gPts = groupPointsByTm.get(tmId) ?? 0;
|
||||
const wins = koWins.get(tmId) || 0;
|
||||
const inKo = playedKo.has(tmId);
|
||||
let total;
|
||||
if (inKo) {
|
||||
total = maxGroupPoints + 1 + wins;
|
||||
} else {
|
||||
total = gPts;
|
||||
}
|
||||
|
||||
const { firstName, lastName } = nameForTm(tmId);
|
||||
const row = ensureMember(mid, firstName, lastName);
|
||||
if (row) row.points += total;
|
||||
}
|
||||
}
|
||||
|
||||
return memberTotals;
|
||||
}
|
||||
|
||||
export default {
|
||||
groupPointsFromRankings,
|
||||
computeInternalSinglesStatsForTournament,
|
||||
parseWinnerFromMatch,
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { Op, literal } from 'sequelize';
|
||||
|
||||
|
||||
import { devLog } from '../utils/logger.js';
|
||||
import { computeInternalSinglesStatsForTournament } from './internalTournamentStatsService.js';
|
||||
|
||||
function normalizeJsonConfig(value, label = 'config') {
|
||||
if (value == null) return {};
|
||||
@@ -4279,6 +4280,118 @@ Ve // 2. Neues Turnier anlegen
|
||||
await pairing.destroy();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranglisten für interne Einzel-Turniere (Punkte nach Gruppenplatz + K.-o.) über einen Zeitraum.
|
||||
*/
|
||||
async getInternalTournamentPlayerStats(userToken, clubId, months = 12) {
|
||||
await checkAccess(userToken, clubId);
|
||||
const m = Number(months);
|
||||
const monthsNum = [3, 6, 12].includes(m) ? m : 12;
|
||||
const from = new Date();
|
||||
from.setMonth(from.getMonth() - monthsNum);
|
||||
const fromStr = from.toISOString().slice(0, 10);
|
||||
|
||||
const tournaments = await Tournament.findAll({
|
||||
where: {
|
||||
clubId: +clubId,
|
||||
allowsExternal: false,
|
||||
miniChampionshipYear: { [Op.is]: null },
|
||||
date: { [Op.gte]: fromStr },
|
||||
},
|
||||
attributes: ['id', 'name', 'date'],
|
||||
order: [['date', 'DESC']],
|
||||
});
|
||||
const list = JSON.parse(JSON.stringify(tournaments));
|
||||
|
||||
const memberAgg = new Map();
|
||||
|
||||
for (const t of list) {
|
||||
const classes = await this.getTournamentClasses(userToken, clubId, t.id);
|
||||
const classesJson = classes.map((c) => (c.toJSON ? c.toJSON() : c));
|
||||
const groups = await this.getGroupsWithParticipants(userToken, clubId, t.id);
|
||||
const matches = await this.getTournamentMatches(userToken, clubId, t.id);
|
||||
|
||||
const tmIds = new Set();
|
||||
for (const g of groups || []) {
|
||||
for (const p of g.participants || []) {
|
||||
if (p.id && !p.isExternal) tmIds.add(p.id);
|
||||
}
|
||||
}
|
||||
for (const ma of matches || []) {
|
||||
if (ma.player1Id) tmIds.add(ma.player1Id);
|
||||
if (ma.player2Id) tmIds.add(ma.player2Id);
|
||||
}
|
||||
const tmList = [...tmIds];
|
||||
const tmToMemberId = new Map();
|
||||
const tmToName = new Map();
|
||||
if (tmList.length > 0) {
|
||||
const tms = await TournamentMember.findAll({
|
||||
where: { tournamentId: t.id, id: { [Op.in]: tmList } },
|
||||
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName'] }],
|
||||
});
|
||||
for (const row of tms) {
|
||||
const plain = row.toJSON();
|
||||
if (plain.member?.id) {
|
||||
tmToMemberId.set(plain.id, plain.member.id);
|
||||
tmToName.set(plain.id, {
|
||||
firstName: plain.member.firstName || '',
|
||||
lastName: plain.member.lastName || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const memberTotals = computeInternalSinglesStatsForTournament({
|
||||
groups,
|
||||
matches,
|
||||
classes: classesJson,
|
||||
tmToMemberId,
|
||||
tmToName,
|
||||
});
|
||||
|
||||
for (const [mid, row] of memberTotals) {
|
||||
const ex = memberAgg.get(mid) || {
|
||||
memberId: mid,
|
||||
totalPoints: 0,
|
||||
tournamentCount: 0,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
};
|
||||
ex.totalPoints += row.points;
|
||||
ex.tournamentCount += 1;
|
||||
if (row.firstName) ex.firstName = row.firstName;
|
||||
if (row.lastName) ex.lastName = row.lastName;
|
||||
memberAgg.set(mid, ex);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = [...memberAgg.values()].map((r) => ({
|
||||
memberId: r.memberId,
|
||||
firstName: r.firstName,
|
||||
lastName: r.lastName,
|
||||
totalPoints: r.totalPoints,
|
||||
tournamentCount: r.tournamentCount,
|
||||
averagePoints: r.tournamentCount > 0
|
||||
? Math.round((r.totalPoints / r.tournamentCount) * 1000) / 1000
|
||||
: 0,
|
||||
}));
|
||||
|
||||
const absoluteRanking = [...rows].sort((a, b) =>
|
||||
b.totalPoints - a.totalPoints || (a.lastName || '').localeCompare(b.lastName || '', 'de')
|
||||
);
|
||||
const averageRanking = [...rows].sort((a, b) =>
|
||||
b.averagePoints - a.averagePoints || (a.lastName || '').localeCompare(b.lastName || '', 'de')
|
||||
);
|
||||
|
||||
return {
|
||||
months: monthsNum,
|
||||
fromDate: fromStr,
|
||||
tournamentCount: list.length,
|
||||
absoluteRanking,
|
||||
averageRanking,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new TournamentService();
|
||||
|
||||
Reference in New Issue
Block a user