feat(TournamentStats): enhance internal tournament statistics with age class filtering
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
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:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user