diff --git a/backend/services/internalTournamentStatsService.js b/backend/services/internalTournamentStatsService.js index d79973ff..bd1cad51 100644 --- a/backend/services/internalTournamentStatsService.js +++ b/backend/services/internalTournamentStatsService.js @@ -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} 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} 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 + * @param {Map} [opts.tmToMemberProfile] – pro TM: Mitglied Geburtsdatum + Geschlecht (für Filter) + * @param {Set|null} [opts.allowedAgeKeys] – tt|9|female … tt|adult|open; null = alle + * @param {string} [opts.tournamentDateIso] – Turnierdatum für J/Erwachsenen-Zuordnung * @returns {Map} 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, }; diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 4f8d2284..f3c339e8 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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, diff --git a/backend/utils/ttInternalStatsBuckets.js b/backend/utils/ttInternalStatsBuckets.js new file mode 100644 index 00000000..78eb711e --- /dev/null +++ b/backend/utils/ttInternalStatsBuckets.js @@ -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; +} diff --git a/frontend/src/components/tournament/InternalTournamentStats.vue b/frontend/src/components/tournament/InternalTournamentStats.vue index 2a01639d..ab89784a 100644 --- a/frontend/src/components/tournament/InternalTournamentStats.vue +++ b/frontend/src/components/tournament/InternalTournamentStats.vue @@ -35,7 +35,7 @@

{{ $t('tournaments.internalStatsPointsExplain') }}

-
+
{{ $t('tournaments.internalStatsAgeFilter') }}