From 27f8186d91ccff8daa39d71719b0a72dc3407f76 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Wed, 8 Apr 2026 12:50:20 +0200 Subject: [PATCH] feat(TournamentStats): enhance internal tournament statistics with age class filtering - 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. --- backend/controllers/tournamentController.js | 3 +- .../internalTournamentStatsService.js | 42 ++++ backend/services/tournamentService.js | 88 +++++++- .../tournament/InternalTournamentStats.vue | 196 +++++++++++++++++- frontend/src/i18n/locales/de-CH.json | 6 + frontend/src/i18n/locales/de-extended.json | 6 + frontend/src/i18n/locales/de.json | 6 + frontend/src/i18n/locales/en-AU.json | 6 + frontend/src/i18n/locales/en-GB.json | 6 + frontend/src/i18n/locales/en-US.json | 6 + frontend/src/i18n/locales/es.json | 6 + frontend/src/i18n/locales/fil.json | 6 + frontend/src/i18n/locales/fr.json | 6 + frontend/src/i18n/locales/it.json | 6 + frontend/src/i18n/locales/ja.json | 6 + frontend/src/i18n/locales/pl.json | 6 + frontend/src/i18n/locales/th.json | 6 + frontend/src/i18n/locales/tl.json | 6 + frontend/src/i18n/locales/zh.json | 6 + 19 files changed, 413 insertions(+), 6 deletions(-) diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index dd383232..6d363789 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -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); diff --git a/backend/services/internalTournamentStatsService.js b/backend/services/internalTournamentStatsService.js index 183cd5f4..d79973ff 100644 --- a/backend/services/internalTournamentStatsService.js +++ b/backend/services/internalTournamentStatsService.js @@ -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} 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} opts.tmToMemberId – Turnier-Mitglied-ID -> Vereins-Mitglied-ID * @param {Map} [opts.tmToName] – optional Namen + * @param {Set|null} [opts.allowedAgeKeys] – nur diese Altersklassen-Schlüssel werten; null = alle * @returns {Map} 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, }; diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index fe4bb634..4f8d2284 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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, }; diff --git a/frontend/src/components/tournament/InternalTournamentStats.vue b/frontend/src/components/tournament/InternalTournamentStats.vue index c501957c..68c239f2 100644 --- a/frontend/src/components/tournament/InternalTournamentStats.vue +++ b/frontend/src/components/tournament/InternalTournamentStats.vue @@ -23,7 +23,7 @@