From 0554a68eb79f4abb6ad3e39249e3c6178481341c Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Sat, 28 Mar 2026 11:48:10 +0100 Subject: [PATCH] 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. --- backend/services/tournamentService.js | 193 +++++++++++++----- .../tournament/TournamentResultsTab.vue | 80 +++++++- frontend/src/i18n/locales/de-CH.json | 2 +- frontend/src/i18n/locales/de.json | 2 +- frontend/src/i18n/locales/en-AU.json | 2 +- frontend/src/i18n/locales/en-GB.json | 2 +- frontend/src/i18n/locales/en-US.json | 2 +- frontend/src/views/TournamentTab.vue | 9 +- 8 files changed, 225 insertions(+), 67 deletions(-) diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index 79b447c5..28ee7858 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -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 Abschluss‑Status zurücksetzen, nicht die Einzelsätze match.isFinished = false; + match.isActive = false; + match.tableNumber = null; match.result = null; // optional: entfernt die zusammengefasste Ergebnis‑Spalte await match.save(); } diff --git a/frontend/src/components/tournament/TournamentResultsTab.vue b/frontend/src/components/tournament/TournamentResultsTab.vue index 84a7e5b5..4635ac0b 100644 --- a/frontend/src/components/tournament/TournamentResultsTab.vue +++ b/frontend/src/components/tournament/TournamentResultsTab.vue @@ -42,10 +42,16 @@
-

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

- {{ filteredGroupMatches.length }} +
+

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

+ {{ filteredGroupMatches.length }} +
+
- +
@@ -177,8 +183,18 @@
-

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

- {{ filteredKnockoutMatches.length }} +
+

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

+ {{ filteredKnockoutMatches.length }} +
+
{{ $t('tournaments.round') }}
@@ -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; diff --git a/frontend/src/i18n/locales/de-CH.json b/frontend/src/i18n/locales/de-CH.json index 8d537662..aac4f52a 100644 --- a/frontend/src/i18n/locales/de-CH.json +++ b/frontend/src/i18n/locales/de-CH.json @@ -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.", diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index c85b8c06..75e6b62e 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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.", diff --git a/frontend/src/i18n/locales/en-AU.json b/frontend/src/i18n/locales/en-AU.json index e2d114d3..11913930 100644 --- a/frontend/src/i18n/locales/en-AU.json +++ b/frontend/src/i18n/locales/en-AU.json @@ -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.", diff --git a/frontend/src/i18n/locales/en-GB.json b/frontend/src/i18n/locales/en-GB.json index 38c7a4af..4461905d 100644 --- a/frontend/src/i18n/locales/en-GB.json +++ b/frontend/src/i18n/locales/en-GB.json @@ -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.", diff --git a/frontend/src/i18n/locales/en-US.json b/frontend/src/i18n/locales/en-US.json index df0b0e8d..1c80c928 100644 --- a/frontend/src/i18n/locales/en-US.json +++ b/frontend/src/i18n/locales/en-US.json @@ -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.", diff --git a/frontend/src/views/TournamentTab.vue b/frontend/src/views/TournamentTab.vue index 9d276c35..85707c1c 100644 --- a/frontend/src/views/TournamentTab.vue +++ b/frontend/src/views/TournamentTab.vue @@ -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,