feat(TournamentStats): add internal tournament statistics endpoint and localization updates
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:
Torsten Schulz (local)
2026-04-08 10:40:33 +02:00
parent 50fa07d0b7
commit 4a53801a54
21 changed files with 749 additions and 0 deletions

View File

@@ -59,6 +59,20 @@ export const getTournaments = async (req, res) => {
}
};
/** Ranglisten interne Einzel-Turniere (Gruppen- + K.-o.-Punkte) */
export const getInternalTournamentStats = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const months = req.query.months;
try {
const data = await tournamentService.getInternalTournamentPlayerStats(token, clubId, months);
res.status(200).json(data);
} catch (error) {
console.error(error);
res.status(500).json({ error: error.message });
}
};
// 2. Neues Turnier anlegen
export const addTournament = async (req, res) => {
const { authcode: token } = req.headers;

View File

@@ -34,6 +34,7 @@ import {
updateExternalParticipantSeeded,
setExternalParticipantGaveUp,
getTournamentClasses,
getInternalTournamentStats,
addTournamentClass,
updateTournamentClass,
deleteTournamentClass,
@@ -55,6 +56,8 @@ import { authenticate } from '../middleware/authMiddleware.js';
const router = express.Router();
router.get('/internal-stats/:clubId', authenticate, getInternalTournamentStats);
router.post('/participant', authenticate, addParticipant);
router.post('/participants', authenticate, getParticipants);
router.delete('/participant', authenticate, removeParticipant);

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

View File

@@ -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();