feat(TournamentService, TournamentResultsTab): enhance knockout match handling and UI interactions

- Introduced new functions for determining knockout round order and building preferred knockout matches based on qualifiers.
- Updated TournamentResultsTab to include collapsible sections for group matches and knockout rounds, improving user experience.
- Added data properties and methods to manage the visibility of match sections and handle tournament class checks.
- Refined UI elements for better interaction, including toggle buttons and improved styling for match sections.
This commit is contained in:
Torsten Schulz (local)
2026-03-28 11:48:10 +01:00
parent 92d29dc64e
commit 0554a68eb7
8 changed files with 225 additions and 67 deletions

View File

@@ -203,6 +203,16 @@ function getLoserId(match) {
return (w1 > w2) ? match.player2Id : match.player1Id;
}
function getKnockoutRoundOrder(roundName) {
if (!roundName || typeof roundName !== 'string') return null;
if (roundName === THIRD_PLACE_ROUND) return 98;
if (roundName.includes('Achtelfinale')) return 10;
if (roundName.includes('Viertelfinale')) return 20;
if (roundName.includes('Halbfinale')) return 30;
if (roundName.includes('Finale')) return 40;
return null;
}
function shuffleInPlace(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
@@ -249,6 +259,49 @@ function compareAdvancementCandidates(a, b) {
return Number(a.id || 0) - Number(b.id || 0);
}
function compareQualifierGroups(a, b) {
const aNum = Number(a);
const bNum = Number(b);
const aFinite = Number.isFinite(aNum);
const bFinite = Number.isFinite(bNum);
if (aFinite && bFinite && aNum !== bNum) return aNum - bNum;
return String(a).localeCompare(String(b), 'de');
}
function buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup) {
if (Number(advancingPerGroup) !== 2) return null;
const groups = Object.keys(qualifiersByGroup).sort(compareQualifierGroups);
if (groups.length < 2) return null;
const winners = [];
const runners = [];
for (const groupKey of groups) {
const ranked = Array.isArray(qualifiersByGroup[groupKey]) ? qualifiersByGroup[groupKey] : [];
const winner = ranked.find(q => Number(q.position) === 1) || ranked[0];
const runnerUp = ranked.find(q => Number(q.position) === 2) || ranked[1];
if (!winner || !runnerUp) return null;
winners.push(winner);
runners.push(runnerUp);
}
const runnerOrder = groups.length % 2 === 0
? [...runners].reverse()
: [...runners.slice(1), runners[0]];
const matches = [];
for (let i = 0; i < winners.length; i++) {
const player1 = winners[i];
const player2 = runnerOrder[i];
if (!player1 || !player2 || !player1.id || !player2.id) continue;
if (player1.id === player2.id) return null;
if (String(player1.groupId) === String(player2.groupId)) return null;
matches.push({ player1, player2 });
}
return matches.length > 0 ? matches : null;
}
const THIRD_PLACE_ROUND = 'Spiel um Platz 3';
class TournamentService {
/**
@@ -3011,77 +3064,74 @@ Ve // 2. Neues Turnier anlegen
qualifiersByGroup[groupKey].sort((a, b) => a.position - b.position);
});
// Erstelle eine flache Liste aller Qualifiers, gruppiert nach Gruppen
const groups = Object.keys(qualifiersByGroup);
const advancingPerGroup = t.advancingPerGroup;
// Erstelle Paarungen: 1. gegen letzter weitergekommener, 2. gegen vorletzter, etc.
// Wichtig: Niemand darf gegen jemanden aus der eigenen Gruppe spielen
const matches = [];
const usedQualifiers = new Set();
// Für jede Position (1., 2., 3., etc.)
for (let pos = 1; pos <= advancingPerGroup; pos++) {
// Finde alle Qualifiers mit dieser Position
const qualifiersAtPosition = [];
groups.forEach(groupKey => {
const groupQualifiers = qualifiersByGroup[groupKey];
const qualifierAtPos = groupQualifiers.find(q => q.position === pos);
if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) {
qualifiersAtPosition.push(qualifierAtPos);
}
});
// Paare jeden Qualifier dieser Position mit dem entsprechenden Gegner
// 1. Platz spielt gegen letzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 1. gegen 2.)
// 2. Platz spielt gegen vorletzter weitergekommener Platz (z.B. bei 2 weiterkommenden: 2. gegen 1.)
const opponentPosition = advancingPerGroup - pos + 1;
qualifiersAtPosition.forEach(qualifier => {
// Finde Gegner mit opponentPosition aus einer anderen Gruppe
let opponent = null;
for (const groupKey of groups) {
if (groupKey === qualifier.groupId.toString()) continue; // Nicht aus derselben Gruppe
let matches = buildPreferredLegacyKnockoutMatches(qualifiersByGroup, advancingPerGroup);
if (!matches) {
const groups = Object.keys(qualifiersByGroup).sort(compareQualifierGroups);
// Fallback für Sonderfälle jenseits des Standardfalls "Top 2 aus jeder Gruppe".
// Wichtig: Niemand darf gegen jemanden aus der eigenen Gruppe spielen.
matches = [];
const usedQualifiers = new Set();
for (let pos = 1; pos <= advancingPerGroup; pos++) {
const qualifiersAtPosition = [];
groups.forEach(groupKey => {
const groupQualifiers = qualifiersByGroup[groupKey];
const opponentCandidate = groupQualifiers.find(q =>
q.position === opponentPosition && !usedQualifiers.has(q.id)
);
if (opponentCandidate) {
opponent = opponentCandidate;
break;
const qualifierAtPos = groupQualifiers.find(q => q.position === pos);
if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) {
qualifiersAtPosition.push(qualifierAtPos);
}
}
// Falls kein Gegner gefunden, suche nach einem beliebigen Gegner aus einer anderen Gruppe
if (!opponent) {
});
const opponentPosition = advancingPerGroup - pos + 1;
qualifiersAtPosition.forEach(qualifier => {
let opponent = null;
for (const groupKey of groups) {
if (groupKey === qualifier.groupId.toString()) continue;
const groupQualifiers = qualifiersByGroup[groupKey];
const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id));
const opponentCandidate = groupQualifiers.find(q =>
q.position === opponentPosition && !usedQualifiers.has(q.id)
);
if (opponentCandidate) {
opponent = opponentCandidate;
break;
}
}
}
if (opponent) {
matches.push({ player1: qualifier, player2: opponent });
usedQualifiers.add(qualifier.id);
usedQualifiers.add(opponent.id);
}
});
if (!opponent) {
for (const groupKey of groups) {
if (groupKey === qualifier.groupId.toString()) continue;
const groupQualifiers = qualifiersByGroup[groupKey];
const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id));
if (opponentCandidate) {
opponent = opponentCandidate;
break;
}
}
}
if (opponent) {
matches.push({ player1: qualifier, player2: opponent });
usedQualifiers.add(qualifier.id);
usedQualifiers.add(opponent.id);
}
});
}
}
// Falls Qualifiers übrig bleiben (ungerade Teilnehmerzahl / keine gültige Paarung möglich):
// Freilos vergeben. Wir erzeugen KEIN Match mit doppelten Spielern.
// Der Spieler mit Freilos wird in späteren Runden berücksichtigt, sobald dort (durch Ergebnisse)
// echte Gegner feststehen. (Passt zur Vorgabe: keine Placeholder-Matches ohne bekannte Spieler.)
const unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifiers.has(q.id));
const usedQualifierIds = new Set(matches.flatMap(match => [match.player1?.id, match.player2?.id]).filter(id => Number.isFinite(id) && id > 0));
const unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifierIds.has(q.id));
if (unusedQualifiers.length > 0) {
devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id));
}
@@ -3551,8 +3601,43 @@ Ve // 2. Neues Turnier anlegen
throw new Error('Spiele mit aufgegebenen Spielern können nicht wieder geöffnet werden.');
}
const currentRoundOrder = getKnockoutRoundOrder(match.round);
if (currentRoundOrder != null) {
const dependentWhere = {
tournamentId,
classId: match.classId ?? null,
id: { [Op.ne]: match.id }
};
if (match.stageId) {
dependentWhere.stageId = match.stageId;
} else {
dependentWhere.stageId = null;
}
if (match.groupId) {
dependentWhere.groupId = match.groupId;
} else {
dependentWhere.groupId = null;
}
const dependentMatches = await TournamentMatch.findAll({ where: dependentWhere });
const matchesToDelete = dependentMatches.filter(candidate => {
const candidateOrder = getKnockoutRoundOrder(candidate.round);
return candidateOrder != null && candidateOrder > currentRoundOrder;
});
if (matchesToDelete.length > 0) {
const dependentIds = matchesToDelete.map(candidate => candidate.id);
await TournamentResult.destroy({ where: { matchId: { [Op.in]: dependentIds } } });
await TournamentMatch.destroy({ where: { id: { [Op.in]: dependentIds } } });
}
}
// Nur den AbschlussStatus zurücksetzen, nicht die Einzelsätze
match.isFinished = false;
match.isActive = false;
match.tableNumber = null;
match.result = null; // optional: entfernt die zusammengefasste ErgebnisSpalte
await match.save();
}