feat(TournamentStats): enhance internal tournament statistics with member profile integration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 37s
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
147
backend/utils/ttInternalStatsBuckets.js
Normal file
147
backend/utils/ttInternalStatsBuckets.js
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Feste TT-Einzel-Filter (J9–J19, 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;
|
||||
}
|
||||
Reference in New Issue
Block a user