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();
}

View File

@@ -42,10 +42,16 @@
</div>
<section v-if="filteredGroupMatches.length" class="group-matches">
<div class="results-section-header">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<span class="results-chip">{{ filteredGroupMatches.length }}</span>
<div class="results-section-title">
<h4>{{ $t('tournaments.groupMatches') }}</h4>
<span class="results-chip">{{ filteredGroupMatches.length }}</span>
</div>
<button type="button" class="section-toggle-btn" @click="groupMatchesCollapsed = !groupMatchesCollapsed">
<span class="collapse-icon" :class="{ expanded: !groupMatchesCollapsed }"></span>
{{ groupMatchesCollapsed ? collapseShowLabel : collapseHideLabel }}
</button>
</div>
<table>
<table v-if="!groupMatchesCollapsed">
<thead>
<tr>
<th>{{ $t('tournaments.round') }}</th>
@@ -177,8 +183,18 @@
</div>
<section v-if="showKnockout && numberOfGroupsForSelectedClass > 1 && filteredKnockoutMatches.length" class="ko-round">
<div class="results-section-header">
<h4>{{ $t('tournaments.koRound') }}</h4>
<span class="results-chip">{{ filteredKnockoutMatches.length }}</span>
<div class="results-section-title">
<h4>{{ $t('tournaments.koRound') }}</h4>
<span class="results-chip">{{ filteredKnockoutMatches.length }}</span>
</div>
<button
v-if="numberOfTables"
type="button"
class="btn-primary btn-inline-action"
@click="$emit('distribute-tables')"
>
{{ $t('tournaments.distributeTables') }}
</button>
</div>
<table>
<thead>
@@ -487,6 +503,25 @@ export default {
'start-knockout',
'reset-knockout'
],
data() {
const show = this.$t('common.show');
const hide = this.$t('common.hide');
return {
groupMatchesCollapsed: false,
collapseShowLabel: show && show !== 'common.show' ? show : 'Anzeigen',
collapseHideLabel: hide && hide !== 'common.hide' ? hide : 'Ausblenden'
};
},
watch: {
showKnockout: {
immediate: true,
handler(newValue) {
if (newValue && this.filteredGroupMatches.length > 0) {
this.groupMatchesCollapsed = true;
}
}
}
},
methods: {
filterMatchesByClass(matches) {
// Wenn keine Klasse ausgewählt ist (null), zeige alle
@@ -707,10 +742,45 @@ export default {
gap: 0.75rem;
}
.results-section-title {
display: flex;
align-items: center;
gap: 0.75rem;
min-width: 0;
}
.results-section-header h4 {
margin: 0 0 0.75rem 0;
}
.section-toggle-btn {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.65rem;
border: 1px solid var(--border-color);
border-radius: 999px;
background: var(--surface-color, #ffffff);
color: var(--text-color);
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
}
.collapse-icon {
display: inline-block;
transform: rotate(-90deg);
transition: transform 0.2s ease;
}
.collapse-icon.expanded {
transform: rotate(0deg);
}
.btn-inline-action {
white-space: nowrap;
}
/* Farbmarkierungen für Spiele */
.match-finished {
background-color: var(--background-soft) !important;

View File

@@ -259,7 +259,7 @@
"conflictSuggestionLabel": "Vorschläg:",
"workspaceProblemsTitle": "{count} offeni Pünkt",
"problemConfigTitle": "Konfiguration unvollständig",
"problemConfigDescription": "Prüef Datum, Name, Gwünnsätz und mindestens e Klass.",
"problemConfigDescription": "Prüef Datum, Name und Gwünnsätz.",
"problemUnassignedTitle": "{count} Teilnehmendi ohni Klass",
"problemUnassignedDescription": "Die Teilnehmendi bruuched no e manuelli Klassenzuewisig.",
"problemUnassignedAutoDescription": "{count} devo chönd direkt automatisch zuegordnet werde.",

View File

@@ -967,7 +967,7 @@
"conflictSuggestionLabel": "Vorschläge:",
"workspaceProblemsTitle": "{count} offene Punkte",
"problemConfigTitle": "Konfiguration unvollständig",
"problemConfigDescription": "Prüfe Datum, Name, Gewinnsätze und mindestens eine Klasse.",
"problemConfigDescription": "Prüfe Datum, Name und Gewinnsätze.",
"problemUnassignedTitle": "{count} Teilnehmer ohne Klasse",
"problemUnassignedDescription": "Diese Teilnehmer brauchen noch eine manuelle Klassenzuordnung.",
"problemUnassignedAutoDescription": "{count} davon können direkt automatisch zugeordnet werden.",

View File

@@ -259,7 +259,7 @@
"conflictSuggestionLabel": "Suggestions:",
"workspaceProblemsTitle": "{count} open issues",
"problemConfigTitle": "Configuration incomplete",
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
"problemConfigDescription": "Check date, name and winning sets.",
"problemUnassignedTitle": "{count} participants without class",
"problemUnassignedDescription": "These participants still need a manual class assignment.",
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",

View File

@@ -529,7 +529,7 @@
"conflictSuggestionLabel": "Suggestions:",
"workspaceProblemsTitle": "{count} open issues",
"problemConfigTitle": "Configuration incomplete",
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
"problemConfigDescription": "Check date, name and winning sets.",
"problemUnassignedTitle": "{count} participants without class",
"problemUnassignedDescription": "These participants still need a manual class assignment.",
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",

View File

@@ -259,7 +259,7 @@
"conflictSuggestionLabel": "Suggestions:",
"workspaceProblemsTitle": "{count} open issues",
"problemConfigTitle": "Configuration incomplete",
"problemConfigDescription": "Check date, name, winning sets and at least one class.",
"problemConfigDescription": "Check date, name and winning sets.",
"problemUnassignedTitle": "{count} participants without class",
"problemUnassignedDescription": "These participants still need a manual class assignment.",
"problemUnassignedAutoDescription": "{count} of them can be assigned automatically right away.",

View File

@@ -483,6 +483,9 @@ export default {
tournamentWideIsDoubles() {
return Boolean(this.currentTournamentWideIsDoubles);
},
hasTournamentClasses() {
return Array.isArray(this.tournamentClasses) && this.tournamentClasses.length > 0;
},
readyParticipantCount() {
return this.totalParticipantCount - this.participantConflictCount - this.unassignedParticipantCount;
},
@@ -493,7 +496,7 @@ export default {
return this.panelTitle;
},
tournamentConfigurationComplete() {
return Boolean(this.currentTournamentDate && (this.currentTournamentName || '').trim() && this.currentWinningSets >= 1 && this.tournamentClasses.length > 0);
return Boolean(this.currentTournamentDate && (this.currentTournamentName || '').trim() && this.currentWinningSets >= 1);
},
groupsFinished() {
return this.groupMatches.length > 0 && this.groupMatches.every(match => match.isFinished);
@@ -520,7 +523,7 @@ export default {
: this.$t('tournaments.statusConfigIncomplete')
});
if (this.unassignedParticipantCount > 0) {
if (this.hasTournamentClasses && this.unassignedParticipantCount > 0) {
statuses.push({
key: 'unassigned',
tone: 'warning',
@@ -660,7 +663,7 @@ export default {
});
}
if (this.unassignedParticipantCount > 0) {
if (this.hasTournamentClasses && this.unassignedParticipantCount > 0) {
problems.push({
key: 'unassigned',
priority: 20,