feat(TournamentStats): enhance internal tournament statistics with age class filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s

- Updated the `getInternalTournamentPlayerStats` endpoint to accept age class keys for more granular statistics.
- Introduced new utility functions for handling age class filtering in the internal tournament stats service.
- Enhanced the InternalTournamentStats component with a new age class filter UI, allowing users to select specific age classes for their statistics.
- Updated localization strings across multiple languages to support the new age class filtering feature, improving user accessibility and understanding.
This commit is contained in:
Torsten Schulz (local)
2026-04-08 12:50:20 +02:00
parent c1b8b2c665
commit 27f8186d91
19 changed files with 413 additions and 6 deletions

View File

@@ -64,8 +64,9 @@ export const getInternalTournamentStats = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId } = req.params;
const months = req.query.months;
const ageClassKeys = req.query.ageClassKeys;
try {
const data = await tournamentService.getInternalTournamentPlayerStats(token, clubId, months);
const data = await tournamentService.getInternalTournamentPlayerStats(token, clubId, months, ageClassKeys);
res.status(200).json(data);
} catch (error) {
console.error(error);

View File

@@ -81,6 +81,35 @@ export function groupPercentFromRankings(rankings, nInGroup) {
return map;
}
/** Schlüssel für Gruppen/Spiele ohne zugeordnete Klasse (classId null) */
export const NO_CLASS_AGE_KEY = 'ak|__noclass__';
/**
* Stabiler Filter-Schlüssel aus einer Einzel-Klasse (Geburtsjahre + Geschlecht).
* @param {object} classRow TournamentClass-Plain
* @returns {string|null} null bei Doppel
*/
export function ageClassFilterKey(classRow) {
if (!classRow || classRow.isDoubles) return null;
const min = classRow.minBirthYear ?? classRow.min_birth_year ?? null;
const max = classRow.maxBirthYear ?? classRow.max_birth_year ?? null;
const g = classRow.gender ?? '';
return `ak|${min ?? ''}|${max ?? ''}|${g}`;
}
/**
* @param {number|null|undefined} classIdNum
* @param {Array<object>} classes
* @returns {string|null} null wenn unbekannte ID oder Doppel-Klasse
*/
export function ageKeyForClassSlice(classIdNum, classes) {
if (classIdNum == null || classIdNum === undefined) return NO_CLASS_AGE_KEY;
const c = (classes || []).find((x) => Number(x.id) === Number(classIdNum));
if (!c) return null;
if (c.isDoubles) return null;
return ageClassFilterKey(c);
}
/**
* @param {object} opts
* @param {Array} opts.groups Aus getGroupsWithParticipants (participants: flache Objekte mit id, position, isExternal, …)
@@ -88,6 +117,7 @@ export function groupPercentFromRankings(rankings, nInGroup) {
* @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
* @param {Set<string>|null} [opts.allowedAgeKeys] nur diese Altersklassen-Schlüssel werten; null = alle
* @returns {Map<number, { points: number, firstName: string, lastName: string }>} Vereins-Mitglied-ID -> Aggregation
*/
export function computeInternalSinglesStatsForTournament({
@@ -96,6 +126,7 @@ export function computeInternalSinglesStatsForTournament({
classes,
tmToMemberId,
tmToName,
allowedAgeKeys = null,
}) {
const doublesClassIds = new Set(
(classes || []).filter((c) => c.isDoubles).map((c) => Number(c.id))
@@ -137,6 +168,14 @@ export function computeInternalSinglesStatsForTournament({
const classIdNum = classKey === 'null' ? null : Number(classKey);
if (classIdNum != null && doublesClassIds.has(classIdNum)) continue;
const sliceAgeKey = ageKeyForClassSlice(classIdNum, classes);
if (sliceAgeKey == null) continue;
if (allowedAgeKeys != null) {
if (allowedAgeKeys.size === 0) continue;
if (!allowedAgeKeys.has(sliceAgeKey)) continue;
}
const classGroups = (groups || []).filter((g) => {
const gc = g.classId != null ? Number(g.classId) : null;
const key = gc != null ? String(gc) : 'null';
@@ -219,4 +258,7 @@ export default {
groupPercentFromRankings,
computeInternalSinglesStatsForTournament,
parseWinnerFromMatch,
ageClassFilterKey,
ageKeyForClassSlice,
NO_CLASS_AGE_KEY,
};

View File

@@ -15,7 +15,30 @@ import { Op, literal } from 'sequelize';
import { devLog } from '../utils/logger.js';
import { computeInternalSinglesStatsForTournament } from './internalTournamentStatsService.js';
import {
computeInternalSinglesStatsForTournament,
ageClassFilterKey,
NO_CLASS_AGE_KEY,
} from './internalTournamentStatsService.js';
/** @param {unknown} val Query ageClassKeys: fehlend = alle; leer = keine; sonst kommagetrennte Schlüssel */
function parseInternalStatsAgeClassKeys(val) {
if (val === undefined || val === null) return null;
if (Array.isArray(val)) {
const out = [];
for (const v of val) {
String(v)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.forEach((k) => out.push(k));
}
return out;
}
const s = String(val).trim();
if (s === '') return [];
return s.split(',').map((x) => x.trim()).filter(Boolean);
}
function normalizeJsonConfig(value, label = 'config') {
if (value == null) return {};
@@ -4283,7 +4306,7 @@ Ve // 2. Neues Turnier anlegen
/**
* Ranglisten für interne Einzel-Turniere (Punkte nach Gruppenplatz + K.-o.) über einen Zeitraum.
*/
async getInternalTournamentPlayerStats(userToken, clubId, months = 12) {
async getInternalTournamentPlayerStats(userToken, clubId, months = 12, ageClassKeysQuery = null) {
await checkAccess(userToken, clubId);
const m = Number(months);
const monthsNum = [3, 6, 12].includes(m) ? m : 12;
@@ -4303,14 +4326,65 @@ Ve // 2. Neues Turnier anlegen
});
const list = JSON.parse(JSON.stringify(tournaments));
const ageClassOptionsByKey = new Map();
const memberAgg = new Map();
const rawKeys = parseInternalStatsAgeClassKeys(ageClassKeysQuery);
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);
for (const c of classesJson) {
if (c.isDoubles) continue;
const k = ageClassFilterKey(c);
if (!k) continue;
if (!ageClassOptionsByKey.has(k)) {
ageClassOptionsByKey.set(k, {
key: k,
name: c.name || '',
minBirthYear: c.minBirthYear ?? c.min_birth_year ?? null,
maxBirthYear: c.maxBirthYear ?? c.max_birth_year ?? null,
gender: c.gender ?? null,
isNoClass: false,
});
}
}
let hasNoClassSlice = false;
for (const g of groups || []) {
if (g.classId == null || g.classId === undefined) {
hasNoClassSlice = true;
break;
}
}
if (!hasNoClassSlice) {
for (const ma of matches || []) {
if (ma.round === 'group') continue;
if (ma.classId == null || ma.classId === undefined) {
hasNoClassSlice = true;
break;
}
}
}
if (hasNoClassSlice && !ageClassOptionsByKey.has(NO_CLASS_AGE_KEY)) {
ageClassOptionsByKey.set(NO_CLASS_AGE_KEY, {
key: NO_CLASS_AGE_KEY,
name: '',
minBirthYear: null,
maxBirthYear: null,
gender: null,
isNoClass: true,
});
}
let allowedAgeKeys = null;
if (rawKeys !== null) {
allowedAgeKeys = new Set(rawKeys);
}
const tmIds = new Set();
for (const g of groups || []) {
for (const p of g.participants || []) {
@@ -4347,6 +4421,7 @@ Ve // 2. Neues Turnier anlegen
classes: classesJson,
tmToMemberId,
tmToName,
allowedAgeKeys,
});
for (const [mid, row] of memberTotals) {
@@ -4383,10 +4458,19 @@ Ve // 2. Neues Turnier anlegen
b.averagePoints - a.averagePoints || (a.lastName || '').localeCompare(b.lastName || '', 'de')
);
const ageClassOptions = [...ageClassOptionsByKey.values()].sort((a, b) => {
if (a.isNoClass && !b.isNoClass) return 1;
if (!a.isNoClass && b.isNoClass) return -1;
const an = (a.name || '').localeCompare(b.name || '', 'de');
if (an !== 0) return an;
return (a.key || '').localeCompare(b.key || '', 'de');
});
return {
months: monthsNum,
fromDate: fromStr,
tournamentCount: list.length,
ageClassOptions,
absoluteRanking,
averageRanking,
};