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

- Integrated member profile data (birth date and gender) into the internal tournament statistics calculations for more accurate age class filtering.
- Removed deprecated age class filtering functions and streamlined the statistics computation process.
- Updated the InternalTournamentStats component to reflect changes in age class options and improved sorting logic.
- Enhanced localization strings across multiple languages to support new terminology related to age classes and gender, improving user accessibility and understanding.
This commit is contained in:
Torsten Schulz (local)
2026-04-08 13:48:41 +02:00
parent 30994adee8
commit bbd9f08e97
19 changed files with 213 additions and 138 deletions

View File

@@ -1,3 +1,5 @@
import { memberToCanonicalTtBucket } from '../utils/ttInternalStatsBuckets.js';
/**
* Statistik-Werte für interne Einzel-Turniere (Gruppenphase + K.-o.-Runde).
*
@@ -6,6 +8,7 @@
* bei N = 1: 100 %. Nur Vereinsmitglieder werden gewertet; N zählt alle Platzierten.
* K.-o.: Wer die K.-o.-Runde erreicht: höchster Gruppen-Prozentwert dieser Klasse + 1,
* danach +1 pro gewonnenes K.-o.-Spiel (wie bisher, jetzt auf %-Skala).
* Alters-/Geschlechtsfilter: pro Spieler aus Vereinsmitglied Geburtsdatum + Geschlecht (nicht aus Turnierklassen-Name).
*/
export function parseWinnerFromMatch(match) {
@@ -81,35 +84,6 @@ 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, …)
@@ -117,7 +91,9 @@ export function ageKeyForClassSlice(classIdNum, classes) {
* @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
* @param {Map<number, { birthDate?: string|null, gender?: string|null }>} [opts.tmToMemberProfile] pro TM: Mitglied Geburtsdatum + Geschlecht (für Filter)
* @param {Set<string>|null} [opts.allowedAgeKeys] tt|9|female … tt|adult|open; null = alle
* @param {string} [opts.tournamentDateIso] Turnierdatum für J/Erwachsenen-Zuordnung
* @returns {Map<number, { points: number, firstName: string, lastName: string }>} Vereins-Mitglied-ID -> Aggregation
*/
export function computeInternalSinglesStatsForTournament({
@@ -126,7 +102,9 @@ export function computeInternalSinglesStatsForTournament({
classes,
tmToMemberId,
tmToName,
tmToMemberProfile,
allowedAgeKeys = null,
tournamentDateIso,
}) {
const doublesClassIds = new Set(
(classes || []).filter((c) => c.isDoubles).map((c) => Number(c.id))
@@ -164,18 +142,12 @@ export function computeInternalSinglesStatsForTournament({
return { firstName: n?.firstName || '', lastName: n?.lastName || '' };
};
const profileForTm = (tmId) => tmToMemberProfile?.get(Number(tmId)) ?? null;
for (const classKey of classKeys) {
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';
@@ -235,6 +207,12 @@ export function computeInternalSinglesStatsForTournament({
const mid = tmToMemberId.get(Number(tmId));
if (mid == null) continue;
const bucket = memberToCanonicalTtBucket(profileForTm(tmId), tournamentDateIso);
if (allowedAgeKeys != null) {
if (allowedAgeKeys.size === 0) continue;
if (bucket == null || !allowedAgeKeys.has(bucket)) continue;
}
const gPts = groupPointsByTm.get(tmId) ?? 0;
const wins = koWins.get(tmId) || 0;
const inKo = playedKo.has(tmId);
@@ -258,7 +236,4 @@ export default {
groupPercentFromRankings,
computeInternalSinglesStatsForTournament,
parseWinnerFromMatch,
ageClassFilterKey,
ageKeyForClassSlice,
NO_CLASS_AGE_KEY,
};

View File

@@ -15,11 +15,8 @@ import { Op, literal } from 'sequelize';
import { devLog } from '../utils/logger.js';
import {
computeInternalSinglesStatsForTournament,
ageClassFilterKey,
NO_CLASS_AGE_KEY,
} from './internalTournamentStatsService.js';
import { computeInternalSinglesStatsForTournament } from './internalTournamentStatsService.js';
import { getCanonicalTtAgeClassOptions } from '../utils/ttInternalStatsBuckets.js';
/** @param {unknown} val Query ageClassKeys: fehlend = alle; leer = keine; sonst kommagetrennte Schlüssel */
function parseInternalStatsAgeClassKeys(val) {
@@ -4326,8 +4323,6 @@ Ve // 2. Neues Turnier anlegen
});
const list = JSON.parse(JSON.stringify(tournaments));
const ageClassOptionsByKey = new Map();
const memberAgg = new Map();
const rawKeys = parseInternalStatsAgeClassKeys(ageClassKeysQuery);
@@ -4338,48 +4333,6 @@ Ve // 2. Neues Turnier anlegen
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);
@@ -4398,10 +4351,11 @@ Ve // 2. Neues Turnier anlegen
const tmList = [...tmIds];
const tmToMemberId = new Map();
const tmToName = new Map();
const tmToMemberProfile = 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'] }],
include: [{ model: Member, as: 'member', attributes: ['id', 'firstName', 'lastName', 'birthDate', 'gender'] }],
});
for (const row of tms) {
const plain = row.toJSON();
@@ -4411,6 +4365,10 @@ Ve // 2. Neues Turnier anlegen
firstName: plain.member.firstName || '',
lastName: plain.member.lastName || '',
});
tmToMemberProfile.set(plain.id, {
birthDate: plain.member.birthDate ?? null,
gender: plain.member.gender ?? null,
});
}
}
}
@@ -4421,7 +4379,9 @@ Ve // 2. Neues Turnier anlegen
classes: classesJson,
tmToMemberId,
tmToName,
tmToMemberProfile,
allowedAgeKeys,
tournamentDateIso: t.date,
});
for (const [mid, row] of memberTotals) {
@@ -4458,13 +4418,7 @@ 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');
});
const ageClassOptions = getCanonicalTtAgeClassOptions();
return {
months: monthsNum,

View File

@@ -0,0 +1,147 @@
/**
* Feste TT-Einzel-Filter (J9J19, Erwachsene × Weiblich/Alle).
* Zuordnung über **Teilnehmer/Mitglied**: Geburtsdatum + Geschlecht, Saison-Stichtag 01.01.
* (vgl. frontend/src/utils/ttAgeClass.js)
*/
const YOUTH_BANDS = [9, 11, 13, 15, 19];
/**
* @param {string|undefined} isoDateStr Turnierdatum (DATEONLY)
* @returns {number} erstes Saisonjahr (01.08.31.07.)
*/
export function getSeasonStartYearFromTournamentDate(isoDateStr) {
if (!isoDateStr) {
const n = new Date();
const month = n.getMonth() + 1;
const year = n.getFullYear();
return month >= 8 ? year : year - 1;
}
const d = new Date(String(isoDateStr).length <= 10 ? `${isoDateStr}T12:00:00` : isoDateStr);
if (Number.isNaN(d.getTime())) {
const n = new Date();
const month = n.getMonth() + 1;
const year = n.getFullYear();
return month >= 8 ? year : year - 1;
}
const month = d.getMonth() + 1;
const year = d.getFullYear();
return month >= 8 ? year : year - 1;
}
function getStichtagDate(seasonStartYear, classNum) {
const y = seasonStartYear - (classNum - 1);
return new Date(y, 0, 1);
}
/**
* @param {number} ms Geburtszeitpunkt
* @param {number} seasonStartYear
* @returns {'J9'|'J11'|'J13'|'J15'|'J19'|'adult'}
*/
function getExclusiveJugendLabelFromBirthTime(ms, seasonStartYear) {
const c = (k) => getStichtagDate(seasonStartYear, k).getTime();
const c9 = c(9);
const c11 = c(11);
const c13 = c(13);
const c15 = c(15);
const c19 = c(19);
if (ms < c19) return 'adult';
if (ms >= c9) return 'J9';
if (ms >= c11 && ms < c9) return 'J11';
if (ms >= c13 && ms < c11) return 'J13';
if (ms >= c15 && ms < c13) return 'J15';
if (ms >= c19 && ms < c15) return 'J19';
return 'adult';
}
/**
* @param {string|Date|null|undefined} birthDate wie Member.birthDate (Klartext nach get)
* @returns {number|null} ms seit Epoch
*/
export function parseMemberBirthDateToMs(birthDate) {
if (birthDate == null || birthDate === '') return null;
if (birthDate instanceof Date && !Number.isNaN(birthDate.getTime())) {
return birthDate.getTime();
}
const t = String(birthDate).trim();
if (/^\d{4}-\d{2}-\d{2}/.test(t)) {
const [a, b, c] = t.split(/[T\s]/)[0].split('-').map(Number);
if (Number.isFinite(a) && Number.isFinite(b) && Number.isFinite(c)) {
const d = new Date(a, b - 1, c);
return Number.isNaN(d.getTime()) ? null : d.getTime();
}
}
if (t.includes('.')) {
const parts = t.split('.');
if (parts.length >= 3) {
const day = parseInt(parts[0], 10);
const month = parseInt(parts[1], 10);
const year = parseInt(parts[2], 10);
if (Number.isFinite(day) && Number.isFinite(month) && Number.isFinite(year)) {
const d = new Date(year, month - 1, day);
return Number.isNaN(d.getTime()) ? null : d.getTime();
}
}
}
return null;
}
/** TT: nur Weiblich vs. Alle (alles außer female → open) */
function memberGenderMode(gender) {
return gender === 'female' ? 'female' : 'open';
}
/**
* Kanonischer Filter-Schlüssel aus Vereinsmitglied (Teilnehmer).
* @param {{ birthDate?: string|Date|null, gender?: string|null }|null|undefined} memberProfile
* @param {string|undefined} tournamentDateIso
* @returns {string|null} z. B. tt|13|open; null ohne auswertbares Geburtsdatum
*/
export function memberToCanonicalTtBucket(memberProfile, tournamentDateIso) {
if (!memberProfile) return null;
const ms = parseMemberBirthDateToMs(memberProfile.birthDate);
if (ms == null) return null;
const seasonStartYear = getSeasonStartYearFromTournamentDate(tournamentDateIso);
const jc = getExclusiveJugendLabelFromBirthTime(ms, seasonStartYear);
const gMode = memberGenderMode(memberProfile.gender);
if (jc === 'adult') return `tt|adult|${gMode}`;
const n = parseInt(jc.slice(1), 10);
if (!YOUTH_BANDS.includes(n)) return `tt|adult|${gMode}`;
return `tt|${n}|${gMode}`;
}
export function getCanonicalTtAgeClassOptions() {
const out = [];
for (const n of YOUTH_BANDS) {
out.push({
key: `tt|${n}|female`,
band: 'youth',
bandNum: n,
genderMode: 'female',
isNoClass: false,
});
out.push({
key: `tt|${n}|open`,
band: 'youth',
bandNum: n,
genderMode: 'open',
isNoClass: false,
});
}
out.push({
key: 'tt|adult|female',
band: 'adult',
bandNum: null,
genderMode: 'female',
isNoClass: false,
});
out.push({
key: 'tt|adult|open',
band: 'adult',
bandNum: null,
genderMode: 'open',
isNoClass: false,
});
return out;
}