From eb54b4f7cf5883357449463919b4a280f86e255a Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 15 May 2026 15:52:54 +0200 Subject: [PATCH] feat(i18n): add scripts for locale translation and patching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implemented `fill-de-extended-gaps.js` to fill missing billing/orders keys in de-extended from de. - Created `fill-i18n-deep.py` for deep translation of locale JSONs using deep-translator with fallback options. - Added `fill-i18n-locales.js` to translate locale JSONs and write overrides for untranslated keys. - Introduced `fix-en-leaks.py` to translate keys that still match the en-US merge, addressing English leaks. - Developed `patch-de-ch-swiss.js` to replace 'ß' with 'ss' in de-CH.json without deleting existing entries. - Created `patch-en-gb-au.js` to apply UK/AU spelling corrections in en-GB and en-AU locales. - Added shell scripts `run-fix-en-leaks.sh` and `run-i18n-deep-fill.sh` for sequential execution of translation tasks. - Implemented `update-i18n-todo-stats.js` to update statistics in the I18N_TODO.md file based on translation completeness. --- .../controllers/memberActivityController.js | 39 +- backend/controllers/tournamentController.js | 153 +- backend/models/TournamentGroup.js | 4 - backend/models/TournamentMatch.js | 6 - backend/models/index.js | 81 - backend/routes/tournamentRoutes.js | 19 - backend/services/schedulerService.js | 33 +- backend/services/tournamentService.js | 1409 +- frontend/I18N_TODO.md | 337 + frontend/package-lock.json | 134 + frontend/package.json | 9 +- frontend/scripts/audit-i18n.js | 26 +- .../tournament/TournamentConfigTab.vue | 2403 +- .../tournament/TournamentGroupsTab.vue | 444 +- .../tournament/TournamentPlacementsTab.vue | 474 +- .../tournament/TournamentResultsTab.vue | 469 +- frontend/src/i18n/CEBUANO_TODO.md | 79 + frontend/src/i18n/index.js | 4 + frontend/src/i18n/locales/ceb.json | 542 + frontend/src/i18n/locales/de-CH.json | 87 +- frontend/src/i18n/locales/de-extended.json | 77 +- frontend/src/i18n/locales/de.json | 3 +- frontend/src/i18n/locales/en-AU.json | 2155 +- frontend/src/i18n/locales/en-GB.json | 2027 +- frontend/src/i18n/locales/en-US.json | 2156 +- frontend/src/i18n/locales/es.json | 2256 +- frontend/src/i18n/locales/fil.json | 2427 +- frontend/src/i18n/locales/fr.json | 2265 +- frontend/src/i18n/locales/it.json | 2257 +- frontend/src/i18n/locales/ja.json | 2402 +- frontend/src/i18n/locales/pl.json | 2259 +- frontend/src/i18n/locales/th.json | 2394 +- frontend/src/i18n/locales/tl.json | 2427 +- frontend/src/i18n/locales/zh.json | 2408 +- frontend/src/views/PersonalSettings.vue | 2 +- frontend/src/views/TournamentTab.vue | 2273 +- mobile-app/gradle/libs.versions.toml | 4 +- .../tt_tagebuch/shared/i18n/MobileStrings.kt | 37938 ++++++++-------- oauth-data.txt | 3 + package.json | 2 +- scripts/.i18n-translate-cache.json | 12542 +++++ scripts/apply-cognate-overrides.js | 124 + scripts/apply-i18n-cache.py | 120 + scripts/check-i18n-completeness.js | 192 + scripts/fill-de-ch-from-de.js | 104 + scripts/fill-de-extended-gaps.js | 65 + scripts/fill-i18n-deep.py | 237 + scripts/fill-i18n-locales.js | 385 + scripts/fix-en-leaks.py | 196 + scripts/patch-de-ch-swiss.js | 49 + scripts/patch-en-gb-au.js | 47 + scripts/run-fix-en-leaks.sh | 19 + scripts/run-i18n-deep-fill.sh | 21 + scripts/update-i18n-todo-stats.js | 80 + 54 files changed, 58003 insertions(+), 30665 deletions(-) create mode 100644 frontend/I18N_TODO.md create mode 100644 frontend/src/i18n/CEBUANO_TODO.md create mode 100644 frontend/src/i18n/locales/ceb.json create mode 100644 oauth-data.txt create mode 100644 scripts/.i18n-translate-cache.json create mode 100644 scripts/apply-cognate-overrides.js create mode 100644 scripts/apply-i18n-cache.py create mode 100644 scripts/check-i18n-completeness.js create mode 100644 scripts/fill-de-ch-from-de.js create mode 100644 scripts/fill-de-extended-gaps.js create mode 100755 scripts/fill-i18n-deep.py create mode 100644 scripts/fill-i18n-locales.js create mode 100644 scripts/fix-en-leaks.py create mode 100644 scripts/patch-de-ch-swiss.js create mode 100644 scripts/patch-en-gb-au.js create mode 100755 scripts/run-fix-en-leaks.sh create mode 100755 scripts/run-i18n-deep-fill.sh create mode 100644 scripts/update-i18n-todo-stats.js diff --git a/backend/controllers/memberActivityController.js b/backend/controllers/memberActivityController.js index c49339ec..3d3f21d9 100644 --- a/backend/controllers/memberActivityController.js +++ b/backend/controllers/memberActivityController.js @@ -7,22 +7,6 @@ import PredefinedActivity from '../models/PredefinedActivity.js'; import GroupActivity from '../models/GroupActivity.js'; import { Op } from 'sequelize'; -const STANDARD_ACTIVITY_NAMES = new Set([ - 'Begrüßung', - 'Aktivierung', - 'Aufbauen', - 'Turnier', - 'Abbauen', - 'Abschlussgespräch', -]); - -const isTrackablePredefinedActivity = (predefinedActivity) => { - if (!predefinedActivity) { - return false; - } - return !predefinedActivity.excludeFromStats && !STANDARD_ACTIVITY_NAMES.has(predefinedActivity.name); -}; - export const getMemberActivities = async (req, res) => { try { const { authcode: userToken } = req.headers; @@ -229,18 +213,12 @@ export const getMemberActivities = async (req, res) => { if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) { return false; } - if (!isTrackablePredefinedActivity(ga.activity.predefinedActivity)) { - return false; - } const key = `${ga.activity.id}-${ga.participant.id}`; return !explicitActivityKeys.has(key); }); // Kombiniere beide Listen - const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities].filter((entry) => { - const predefinedActivity = entry?.activity?.predefinedActivity; - return isTrackablePredefinedActivity(predefinedActivity); - }); + const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities]; // Group activities by name and count occurrences // Verwende einen Set pro Aktivität, um eindeutige Datum-Aktivität-Kombinationen zu tracken @@ -250,10 +228,6 @@ export const getMemberActivities = async (req, res) => { if (!ma.activity || !ma.activity.predefinedActivity || !ma.participant) { continue; } - - if (!isTrackablePredefinedActivity(ma.activity.predefinedActivity)) { - continue; - } const activity = ma.activity.predefinedActivity; const activityName = activity.name; @@ -480,18 +454,12 @@ export const getMemberLastParticipations = async (req, res) => { if (!ga.activity || !ga.activity.id || !ga.participant || !ga.participant.id) { return false; } - if (!isTrackablePredefinedActivity(ga.activity.predefinedActivity)) { - return false; - } const key = `${ga.activity.id}-${ga.participant.id}`; return !explicitActivityKeys.has(key); }); // Kombiniere beide Listen - const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities].filter((entry) => { - const predefinedActivity = entry?.activity?.predefinedActivity; - return isTrackablePredefinedActivity(predefinedActivity); - }); + const allActivities = [...filteredMemberActivities, ...uniqueGroupActivities]; // Gruppiere nach Datum const participationsByDate = new Map(); @@ -501,7 +469,7 @@ export const getMemberLastParticipations = async (req, res) => { if (!ma.activity || !ma.activity.predefinedActivity || !ma.activity.diaryDate || !ma.participant) { return false; } - return isTrackablePredefinedActivity(ma.activity.predefinedActivity); + return true; }) .forEach(ma => { const date = ma.activity.diaryDate.date; @@ -560,3 +528,4 @@ export const getMemberLastParticipations = async (req, res) => { return res.status(500).json({ error: 'Failed to fetch member last participations' }); } }; + diff --git a/backend/controllers/tournamentController.js b/backend/controllers/tournamentController.js index 6d363789..669e764f 100644 --- a/backend/controllers/tournamentController.js +++ b/backend/controllers/tournamentController.js @@ -4,49 +4,12 @@ import { emitTournamentChanged } from '../services/socketService.js'; import TournamentClass from '../models/TournamentClass.js'; import HttpError from '../exceptions/HttpError.js'; -// Pools (zusammengelegte Gruppenphasen) -export const mergeClassesIntoPool = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId, sourceClassId, targetClassId, strategy, outOfCompetitionForSource } = req.body; - try { - await tournamentService.mergeClassesIntoPool( - token, - clubId, - tournamentId, - sourceClassId, - targetClassId, - strategy, // 'singleGroup' | 'distribute' - !!outOfCompetitionForSource - ); - // Broadcast - emitTournamentChanged(clubId, tournamentId); - res.status(200).json({ success: true }); - } catch (error) { - console.error('[mergeClassesIntoPool] Error:', error); - res.status(500).json({ error: error.message }); - } -}; - -export const resetPool = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId, poolId } = req.body; - try { - await tournamentService.resetPool(token, clubId, tournamentId, poolId); - emitTournamentChanged(clubId, tournamentId); - res.status(200).json({ success: true }); - } catch (error) { - console.error('[resetPool] Error:', error); - res.status(500).json({ error: error.message }); - } -}; - -// 1. Alle Turniere eines Vereins (query: type = 'internal' | 'external' | 'mini') +// 1. Alle Turniere eines Vereins export const getTournaments = async (req, res) => { const { authcode: token } = req.headers; const { clubId } = req.params; - const type = req.query.type || null; try { - const tournaments = await tournamentService.getTournaments(token, clubId, type); + const tournaments = await tournamentService.getTournaments(token, clubId); res.status(200).json(tournaments); } catch (error) { console.error(error); @@ -59,27 +22,13 @@ export const getTournaments = async (req, res) => { } }; -/** Ranglisten interne Einzel-Turniere (Gruppen-% + K.-o.-Bonus wie im Service) */ -export const getInternalTournamentStats = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId } = req.params; - const months = req.query.months; - const ageClassKeys = req.query.ageClassKeys; - try { - const data = await tournamentService.getInternalTournamentPlayerStats(token, clubId, months, ageClassKeys); - res.status(200).json(data); - } catch (error) { - console.error(error); - res.status(500).json({ error: error.message }); - } -}; - // 2. Neues Turnier anlegen export const addTournament = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament } = req.body; + const { clubId, tournamentName, date, winningSets, allowsExternal } = req.body; try { - const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament); + const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets, allowsExternal); + // Emit Socket-Event if (clubId && tournament && tournament.id) { emitTournamentChanged(clubId, tournament.id); } @@ -90,22 +39,6 @@ export const addTournament = async (req, res) => { } }; -// Minimeisterschaft anlegen (Turnier + 6 Klassen); Name: "Minimeisterschaften Ortsentscheid " -export const addMiniChampionship = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, ort, date, year, winningSets } = req.body; - try { - const tournament = await tournamentService.addMiniChampionship(token, clubId, ort, date, year, winningSets); - if (clubId && tournament && tournament.id) { - emitTournamentChanged(clubId, tournament.id); - } - res.status(201).json(tournament); - } catch (error) { - console.error('[addMiniChampionship] Error:', error); - res.status(500).json({ error: error.message }); - } -}; - // 3. Teilnehmer hinzufügen - klassengebunden export const addParticipant = async (req, res) => { const { authcode: token } = req.headers; @@ -286,9 +219,9 @@ export const getTournament = async (req, res) => { export const updateTournament = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId } = req.params; - const { name, date, winningSets, numberOfTables, isDoublesTournament } = req.body; + const { name, date, winningSets } = req.body; try { - const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets, numberOfTables, isDoublesTournament); + const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets); // Emit Socket-Event emitTournamentChanged(clubId, tournamentId); res.status(200).json(tournament); @@ -435,19 +368,6 @@ export const resetMatches = async (req, res) => { } }; -export const cleanupOrphanedMatches = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId } = req.body; - try { - const result = await tournamentService.cleanupOrphanedMatches(token, clubId, tournamentId); - emitTournamentChanged(clubId, tournamentId); - res.status(200).json(result); - } catch (err) { - console.error(err); - res.status(500).json({ error: err.message }); - } -}; - export const removeParticipant = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId, participantId } = req.body; @@ -478,20 +398,6 @@ export const updateParticipantSeeded = async (req, res) => { } }; -export const setParticipantGaveUp = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId, participantId } = req.params; - const { gaveUp } = req.body; - try { - await tournamentService.setParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp); - emitTournamentChanged(clubId, tournamentId); - res.status(200).json({ message: 'Aufgabe-Status aktualisiert' }); - } catch (err) { - console.error('[setParticipantGaveUp] Error:', err); - res.status(500).json({ error: err.message }); - } -}; - export const deleteMatchResult = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId, matchId, set } = req.body; @@ -557,27 +463,12 @@ export const setMatchActive = async (req, res) => { } }; -export const setMatchTableNumber = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId, matchId } = req.params; - const { tableNumber } = req.body; - try { - await tournamentService.setMatchTableNumber(token, clubId, tournamentId, matchId, tableNumber); - // Emit Socket-Event - emitTournamentChanged(clubId, tournamentId); - res.status(200).json({ message: 'Tischnummer aktualisiert' }); - } catch (err) { - console.error('[setMatchTableNumber] Error:', err); - res.status(500).json({ error: err.message }); - } -}; - // Externe Teilnehmer hinzufügen export const addExternalParticipant = async (req, res) => { const { authcode: token } = req.headers; - const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender, email, address } = req.body; + const { clubId, tournamentId, classId, firstName, lastName, club, birthDate, gender } = req.body; try { - await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender, email, address); + await tournamentService.addExternalParticipant(token, clubId, classId, firstName, lastName, club, birthDate, gender); emitTournamentChanged(clubId, tournamentId); res.status(200).json({ message: 'Externer Teilnehmer hinzugefügt' }); } catch (error) { @@ -628,20 +519,6 @@ export const updateExternalParticipantSeeded = async (req, res) => { } }; -export const setExternalParticipantGaveUp = async (req, res) => { - const { authcode: token } = req.headers; - const { clubId, tournamentId, participantId } = req.params; - const { gaveUp } = req.body; - try { - await tournamentService.setExternalParticipantGaveUp(token, clubId, tournamentId, participantId, gaveUp); - emitTournamentChanged(clubId, tournamentId); - res.status(200).json({ message: 'Aufgabe-Status aktualisiert' }); - } catch (error) { - console.error('[setExternalParticipantGaveUp] Error:', error); - res.status(500).json({ error: error.message }); - } -}; - // Tournament Classes export const getTournamentClasses = async (req, res) => { const { authcode: token } = req.headers; @@ -658,9 +535,9 @@ export const getTournamentClasses = async (req, res) => { export const addTournamentClass = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId } = req.params; - const { name, isDoubles, gender, minBirthYear, maxBirthYear } = req.body; + const { name, isDoubles, gender, minBirthYear } = req.body; try { - const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear, maxBirthYear); + const tournamentClass = await tournamentService.addTournamentClass(token, clubId, tournamentId, name, isDoubles, gender, minBirthYear); emitTournamentChanged(clubId, tournamentId); res.status(200).json(tournamentClass); } catch (error) { @@ -672,9 +549,11 @@ export const addTournamentClass = async (req, res) => { export const updateTournamentClass = async (req, res) => { const { authcode: token } = req.headers; const { clubId, tournamentId, classId } = req.params; - const { name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear } = req.body; + const { name, sortOrder, isDoubles, gender, minBirthYear } = req.body; try { - const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear); + console.log('[updateTournamentClass] Request body:', { name, sortOrder, isDoubles, gender, minBirthYear }); + const tournamentClass = await tournamentService.updateTournamentClass(token, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear); + console.log('[updateTournamentClass] Updated class:', JSON.stringify(tournamentClass.toJSON(), null, 2)); emitTournamentChanged(clubId, tournamentId); res.status(200).json(tournamentClass); } catch (error) { @@ -763,4 +642,4 @@ export const deletePairing = async (req, res) => { res.status(500).json({ error: error.message }); } }; - + \ No newline at end of file diff --git a/backend/models/TournamentGroup.js b/backend/models/TournamentGroup.js index 77bf254a..2c47f291 100644 --- a/backend/models/TournamentGroup.js +++ b/backend/models/TournamentGroup.js @@ -20,10 +20,6 @@ const TournamentGroup = sequelize.define('TournamentGroup', { type: DataTypes.INTEGER, allowNull: true }, - poolId: { - type: DataTypes.INTEGER, - allowNull: true - }, }, { underscored: true, tableName: 'tournament_group', diff --git a/backend/models/TournamentMatch.js b/backend/models/TournamentMatch.js index 2e4b8e6a..f2fbb92e 100644 --- a/backend/models/TournamentMatch.js +++ b/backend/models/TournamentMatch.js @@ -63,12 +63,6 @@ const TournamentMatch = sequelize.define('TournamentMatch', { type: DataTypes.STRING, allowNull: true, }, - tableNumber: { - type: DataTypes.INTEGER, - allowNull: true, - defaultValue: null, - comment: 'Tischnummer, an der das Match stattfindet' - }, }, { underscored: true, tableName: 'tournament_match', diff --git a/backend/models/index.js b/backend/models/index.js index 82113b9f..fdc8b4b1 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -20,7 +20,6 @@ import Match from './Match.js'; import League from './League.js'; import Team from './Team.js'; import ClubTeam from './ClubTeam.js'; -import ClubTeamMember from './ClubTeamMember.js'; import TeamDocument from './TeamDocument.js'; import Season from './Season.js'; import Location from './Location.js'; @@ -42,30 +41,16 @@ import OfficialTournament from './OfficialTournament.js'; import OfficialCompetition from './OfficialCompetition.js'; import OfficialCompetitionMember from './OfficialCompetitionMember.js'; import MyTischtennis from './MyTischtennis.js'; -import ClickTtAccount from './ClickTtAccount.js'; import MyTischtennisUpdateHistory from './MyTischtennisUpdateHistory.js'; import MyTischtennisFetchLog from './MyTischtennisFetchLog.js'; import ApiLog from './ApiLog.js'; import MemberTransferConfig from './MemberTransferConfig.js'; import MemberContact from './MemberContact.js'; import MemberImage from './MemberImage.js'; -import MemberGroupPhoto from './MemberGroupPhoto.js'; -import MemberTtrHistory from './MemberTtrHistory.js'; -import MemberPlayInterest from './MemberPlayInterest.js'; -import MemberOrder from './MemberOrder.js'; -import MemberOrderHistory from './MemberOrderHistory.js'; import TrainingGroup from './TrainingGroup.js'; import MemberTrainingGroup from './MemberTrainingGroup.js'; import ClubDisabledPresetGroup from './ClubDisabledPresetGroup.js'; import TrainingTime from './TrainingTime.js'; -import TrainingCancellation from './TrainingCancellation.js'; -import CalendarEvent from './CalendarEvent.js'; -import BillingTemplate from './BillingTemplate.js'; -import BillingTemplateField from './BillingTemplateField.js'; -import BillingRun from './BillingRun.js'; -import BillingDocument from './BillingDocument.js'; -import BillingDocumentValue from './BillingDocumentValue.js'; -import BillingUserSetting from './BillingUserSetting.js'; // Official tournaments relations OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' }); OfficialCompetition.belongsTo(OfficialTournament, { foreignKey: 'tournamentId', as: 'tournament' }); @@ -104,25 +89,6 @@ DiaryNote.belongsTo(Member, { foreignKey: 'memberId' }); Member.hasMany(MemberNote, { as: 'memberNotes', foreignKey: 'memberId' }); MemberNote.belongsTo(Member, { foreignKey: 'memberId' }); -Member.hasMany(MemberTtrHistory, { as: 'ttrHistoryEntries', foreignKey: 'memberId' }); -MemberTtrHistory.belongsTo(Member, { as: 'member', foreignKey: 'memberId' }); -Member.hasMany(MemberPlayInterest, { as: 'playInterests', foreignKey: 'memberId' }); -MemberPlayInterest.belongsTo(Member, { as: 'member', foreignKey: 'memberId' }); -Club.hasMany(MemberPlayInterest, { as: 'memberPlayInterests', foreignKey: 'clubId' }); -MemberPlayInterest.belongsTo(Club, { as: 'club', foreignKey: 'clubId' }); - -Club.hasMany(MemberGroupPhoto, { as: 'memberGroupPhotos', foreignKey: 'clubId' }); -MemberGroupPhoto.belongsTo(Club, { as: 'club', foreignKey: 'clubId' }); -User.hasMany(MemberGroupPhoto, { as: 'createdMemberGroupPhotos', foreignKey: 'createdByUserId' }); -MemberGroupPhoto.belongsTo(User, { as: 'createdByUser', foreignKey: 'createdByUserId' }); - -Member.hasMany(MemberOrder, { as: 'orders', foreignKey: 'memberId' }); -MemberOrder.belongsTo(Member, { as: 'member', foreignKey: 'memberId' }); -Club.hasMany(MemberOrder, { as: 'memberOrders', foreignKey: 'clubId' }); -MemberOrder.belongsTo(Club, { as: 'club', foreignKey: 'clubId' }); -MemberOrder.hasMany(MemberOrderHistory, { as: 'historyEntries', foreignKey: 'memberOrderId' }); -MemberOrderHistory.belongsTo(MemberOrder, { as: 'order', foreignKey: 'memberOrderId' }); - DiaryDate.hasMany(DiaryNote, { as: 'diaryNotes', foreignKey: 'diaryDateId' }); DiaryNote.belongsTo(DiaryDate, { foreignKey: 'diaryDateId' }); @@ -188,11 +154,6 @@ ClubTeam.belongsTo(League, { foreignKey: 'leagueId', as: 'league' }); Season.hasMany(ClubTeam, { foreignKey: 'seasonId', as: 'clubTeams' }); ClubTeam.belongsTo(Season, { foreignKey: 'seasonId', as: 'season' }); -ClubTeam.hasMany(ClubTeamMember, { foreignKey: 'clubTeamId', as: 'lineupEntries' }); -ClubTeamMember.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' }); -Member.hasMany(ClubTeamMember, { foreignKey: 'memberId', as: 'clubTeamAssignments' }); -ClubTeamMember.belongsTo(Member, { foreignKey: 'memberId', as: 'member' }); - // TeamDocument relationships ClubTeam.hasMany(TeamDocument, { foreignKey: 'clubTeamId', as: 'documents' }); TeamDocument.belongsTo(ClubTeam, { foreignKey: 'clubTeamId', as: 'clubTeam' }); @@ -221,9 +182,6 @@ GroupActivity.belongsTo(Group, { foreignKey: 'groupId', as: 'groupsGroupActivity GroupActivity.belongsTo(PredefinedActivity, { foreignKey: 'customActivity', as: 'groupPredefinedActivity' }); PredefinedActivity.hasMany(GroupActivity, { foreignKey: 'predefinedActivityId', as: 'groupPredefinedActivities' }); -DiaryDateActivity.belongsTo(Group, { foreignKey: 'groupId', as: 'planGroup' }); -Group.hasMany(DiaryDateActivity, { foreignKey: 'groupId', as: 'groupPlanItems' }); - DiaryTag.hasMany(DiaryDateTag, { foreignKey: 'tagId', as: 'diaryDateTags' }); DiaryDateTag.belongsTo(DiaryTag, { foreignKey: 'tagId', as: 'tag' }); @@ -364,8 +322,6 @@ DiaryDate.hasMany(Accident, { foreignKey: 'diaryDateId', as: 'accidents' }); User.hasOne(MyTischtennis, { foreignKey: 'userId', as: 'myTischtennis' }); MyTischtennis.belongsTo(User, { foreignKey: 'userId', as: 'user' }); -User.hasOne(ClickTtAccount, { foreignKey: 'userId', as: 'clickTtAccount' }); -ClickTtAccount.belongsTo(User, { foreignKey: 'userId', as: 'user' }); User.hasMany(MyTischtennisUpdateHistory, { foreignKey: 'userId', as: 'updateHistory' }); MyTischtennisUpdateHistory.belongsTo(User, { foreignKey: 'userId', as: 'user' }); @@ -409,28 +365,6 @@ ClubDisabledPresetGroup.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); // Training Times TrainingGroup.hasMany(TrainingTime, { foreignKey: 'trainingGroupId', as: 'trainingTimes' }); TrainingTime.belongsTo(TrainingGroup, { foreignKey: 'trainingGroupId', as: 'trainingGroup' }); -Club.hasMany(TrainingCancellation, { foreignKey: 'clubId', as: 'trainingCancellations' }); -TrainingCancellation.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); -Club.hasMany(CalendarEvent, { foreignKey: 'clubId', as: 'calendarEvents' }); -CalendarEvent.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); - -// Billing -Club.hasMany(BillingTemplate, { foreignKey: 'clubId', as: 'billingTemplates' }); -BillingTemplate.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); -BillingTemplate.hasMany(BillingTemplateField, { foreignKey: 'templateId', as: 'fields' }); -BillingTemplateField.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' }); -Club.hasMany(BillingRun, { foreignKey: 'clubId', as: 'billingRuns' }); -BillingRun.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); -BillingTemplate.hasMany(BillingRun, { foreignKey: 'templateId', as: 'runs' }); -BillingRun.belongsTo(BillingTemplate, { foreignKey: 'templateId', as: 'template' }); -BillingRun.hasMany(BillingDocument, { foreignKey: 'runId', as: 'documents' }); -BillingDocument.belongsTo(BillingRun, { foreignKey: 'runId', as: 'run' }); -BillingDocument.hasMany(BillingDocumentValue, { foreignKey: 'billingDocumentId', as: 'values' }); -BillingDocumentValue.belongsTo(BillingDocument, { foreignKey: 'billingDocumentId', as: 'document' }); -Club.hasMany(BillingUserSetting, { foreignKey: 'clubId', as: 'billingUserSettings' }); -BillingUserSetting.belongsTo(Club, { foreignKey: 'clubId', as: 'club' }); -User.hasMany(BillingUserSetting, { foreignKey: 'userId', as: 'billingUserSettings' }); -BillingUserSetting.belongsTo(User, { foreignKey: 'userId', as: 'user' }); export { User, @@ -456,7 +390,6 @@ export { League, Team, ClubTeam, - ClubTeamMember, TeamDocument, Group, GroupActivity, @@ -474,28 +407,14 @@ export { OfficialCompetition, OfficialCompetitionMember, MyTischtennis, - ClickTtAccount, MyTischtennisUpdateHistory, MyTischtennisFetchLog, ApiLog, MemberTransferConfig, MemberContact, MemberImage, - MemberGroupPhoto, - MemberTtrHistory, - MemberPlayInterest, - MemberOrder, - MemberOrderHistory, TrainingGroup, MemberTrainingGroup, ClubDisabledPresetGroup, TrainingTime, - TrainingCancellation, - CalendarEvent, - BillingTemplate, - BillingTemplateField, - BillingRun, - BillingDocument, - BillingDocumentValue, - BillingUserSetting, }; diff --git a/backend/routes/tournamentRoutes.js b/backend/routes/tournamentRoutes.js index ee79146d..f1a409af 100644 --- a/backend/routes/tournamentRoutes.js +++ b/backend/routes/tournamentRoutes.js @@ -2,7 +2,6 @@ import express from 'express'; import { getTournaments, addTournament, - addMiniChampionship, updateTournament, addParticipant, getParticipants, @@ -19,22 +18,17 @@ import { manualAssignGroups, resetGroups, resetMatches, - cleanupOrphanedMatches, removeParticipant, updateParticipantSeeded, - setParticipantGaveUp, deleteMatchResult, reopenMatch, deleteKnockoutMatches, setMatchActive, - setMatchTableNumber, addExternalParticipant, getExternalParticipants, removeExternalParticipant, updateExternalParticipantSeeded, - setExternalParticipantGaveUp, getTournamentClasses, - getInternalTournamentStats, addTournamentClass, updateTournamentClass, deleteTournamentClass, @@ -46,7 +40,6 @@ import { updatePairing, deletePairing, } from '../controllers/tournamentController.js'; -import { mergeClassesIntoPool, resetPool } from '../controllers/tournamentController.js'; import { getStages, upsertStages, @@ -56,31 +49,23 @@ import { authenticate } from '../middleware/authMiddleware.js'; const router = express.Router(); -router.get('/internal-stats/:clubId', authenticate, getInternalTournamentStats); - router.post('/participant', authenticate, addParticipant); router.post('/participants', authenticate, getParticipants); router.delete('/participant', authenticate, removeParticipant); router.put('/participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateParticipantSeeded); -router.put('/participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setParticipantGaveUp); router.post('/modus', authenticate, setModus); router.post('/groups/reset', authenticate, resetGroups); router.post('/matches/reset', authenticate, resetMatches); -router.post('/matches/cleanup-orphaned', authenticate, cleanupOrphanedMatches); router.put('/groups', authenticate, createGroups); router.post('/groups/create', authenticate, createGroupsPerClass); router.post('/groups', authenticate, fillGroups); router.post('/matches/create', authenticate, createGroupMatches); -// Pools -router.post('/pools/merge', authenticate, mergeClassesIntoPool); -router.post('/pools/reset', authenticate, resetPool); router.get('/groups', authenticate, getGroups); router.post('/match/result', authenticate, addMatchResult); router.delete('/match/result', authenticate, deleteMatchResult); router.post("/match/reopen", authenticate, reopenMatch); router.post('/match/finish', authenticate, finishMatch); router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive); -router.put('/match/:clubId/:tournamentId/:matchId/table', authenticate, setMatchTableNumber); router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches); router.post('/knockout', authenticate, startKnockout); router.delete("/matches/knockout", authenticate, deleteKnockoutMatches); @@ -93,7 +78,6 @@ router.post('/external-participant', authenticate, addExternalParticipant); router.post('/external-participants', authenticate, getExternalParticipants); router.delete('/external-participant', authenticate, removeExternalParticipant); router.put('/external-participant/:clubId/:tournamentId/:participantId/seeded', authenticate, updateExternalParticipantSeeded); -router.put('/external-participant/:clubId/:tournamentId/:participantId/gave-up', authenticate, setExternalParticipantGaveUp); // Tournament Classes router.get('/classes/:clubId/:tournamentId', authenticate, getTournamentClasses); @@ -113,9 +97,6 @@ router.get('/stages', authenticate, getStages); router.put('/stages', authenticate, upsertStages); router.post('/stages/advance', authenticate, advanceStage); -// Minimeisterschaft anlegen (vor :clubId, damit 'mini' nicht als clubId matcht) -router.post('/mini', authenticate, addMiniChampionship); - // Muss NACH allen festen Pfaden stehen, sonst matcht z.B. '/stages' als clubId='stages' router.get('/:clubId', authenticate, getTournaments); router.post('/', authenticate, addTournament); diff --git a/backend/services/schedulerService.js b/backend/services/schedulerService.js index a428269f..5d4b3462 100644 --- a/backend/services/schedulerService.js +++ b/backend/services/schedulerService.js @@ -2,7 +2,6 @@ import cron from 'node-cron'; import autoUpdateRatingsService from './autoUpdateRatingsService.js'; import autoFetchMatchResultsService from './autoFetchMatchResultsService.js'; import apiLogService from './apiLogService.js'; -import memberService from './memberService.js'; import { devLog } from '../utils/logger.js'; class SchedulerService { @@ -25,10 +24,10 @@ class SchedulerService { } } - async runMatchResultsFetchJob(isAutomatic = true, options = {}) { + async runMatchResultsFetchJob(isAutomatic = true) { const startTime = Date.now(); try { - const result = await autoFetchMatchResultsService.executeAutomaticFetch(options); + const result = await autoFetchMatchResultsService.executeAutomaticFetch(); const executionTime = Date.now() - startTime; await apiLogService.logSchedulerExecution('match_results', true, result || { success: true }, executionTime, null); return { success: true, result, executionTime, isAutomatic }; @@ -39,20 +38,6 @@ class SchedulerService { } } - async runInactiveMemberCleanupJob(isAutomatic = true) { - const startTime = Date.now(); - try { - const result = await memberService.cleanupInactiveMembersWithoutRecentTraining({ inactiveDays: 365 }); - const executionTime = Date.now() - startTime; - await apiLogService.logSchedulerExecution('member_cleanup', true, result || { success: true }, executionTime, null); - return { success: true, result, executionTime, isAutomatic }; - } catch (error) { - const executionTime = Date.now() - startTime; - await apiLogService.logSchedulerExecution('member_cleanup', false, { success: false }, executionTime, error?.message || String(error)); - return { success: false, error: error?.message || String(error), executionTime, isAutomatic }; - } - } - /** * Start the scheduler */ @@ -80,16 +65,8 @@ class SchedulerService { timezone: 'Europe/Berlin' }); - const inactiveMemberCleanupJob = cron.schedule('45 6 * * *', async () => { - devLog('[Scheduler] Running inactive member cleanup job...'); - await this.runInactiveMemberCleanupJob(true); - }, { - timezone: 'Europe/Berlin' - }); - this.jobs.set('ratingUpdates', ratingUpdateJob); this.jobs.set('matchResults', matchResultsJob); - this.jobs.set('inactiveMemberCleanup', inactiveMemberCleanupJob); this.isRunning = true; const now = new Date(); @@ -138,6 +115,7 @@ class SchedulerService { /** * Manually trigger rating updates (for testing) + * HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar */ async triggerRatingUpdates() { devLog('[Scheduler] Manual rating updates trigger called'); @@ -146,10 +124,11 @@ class SchedulerService { /** * Manually trigger match results fetch (for testing) + * HINWEIS: Deaktiviert - automatische MyTischtennis-Abrufe sind nicht mehr verfügbar */ - async triggerMatchResultsFetch(options = {}) { + async triggerMatchResultsFetch() { devLog('[Scheduler] Manual match results fetch trigger called'); - return await this.runMatchResultsFetchJob(false, options); + return await this.runMatchResultsFetchJob(false); } /** diff --git a/backend/services/tournamentService.js b/backend/services/tournamentService.js index df5627ad..45810f8d 100644 --- a/backend/services/tournamentService.js +++ b/backend/services/tournamentService.js @@ -15,27 +15,6 @@ import { Op, literal } from 'sequelize'; import { devLog } from '../utils/logger.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) { - if (val === undefined || val === null) return null; - if (Array.isArray(val)) { - const out = []; - for (const v of val) { - String(v) - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - .forEach((k) => out.push(k)); - } - return out; - } - const s = String(val).trim(); - if (s === '') return []; - return s.split(',').map((x) => x.trim()).filter(Boolean); -} function normalizeJsonConfig(value, label = 'config') { if (value == null) return {}; @@ -52,129 +31,6 @@ function normalizeJsonConfig(value, label = 'config') { } return {}; } - -function validateStageAdvancements(stages = [], advList = []) { - const normalizedStages = (Array.isArray(stages) ? stages : []) - .filter(stage => stage && stage.index != null && stage.type) - .map(stage => ({ - index: Number(stage.index), - type: stage.type, - numberOfGroups: stage.numberOfGroups != null ? Number(stage.numberOfGroups) : null, - })); - - const stageByIndex = new Map(normalizedStages.map(stage => [stage.index, stage])); - - for (const stage of normalizedStages) { - if (!Number.isInteger(stage.index) || stage.index < 1) { - throw new Error('Stage-Index ist ungültig.'); - } - if (!['groups', 'knockout'].includes(stage.type)) { - throw new Error(`Stage ${stage.index} hat einen ungültigen Typ.`); - } - if (stage.type === 'groups' && (!Number.isInteger(stage.numberOfGroups) || stage.numberOfGroups < 1)) { - throw new Error(`Stage ${stage.index} benötigt mindestens eine Gruppe.`); - } - } - - for (const adv of (Array.isArray(advList) ? advList : [])) { - if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue; - const fromIndex = Number(adv.fromStageIndex); - const toIndex = Number(adv.toStageIndex); - const fromStage = stageByIndex.get(fromIndex); - const toStage = stageByIndex.get(toIndex); - - if (!fromStage || !toStage) { - throw new Error('Advancement verweist auf unbekannte Stages.'); - } - if (toIndex <= fromIndex) { - throw new Error(`Advancement ${fromIndex}→${toIndex} ist ungültig.`); - } - - const config = normalizeJsonConfig(adv.config, 'Advancement-Konfiguration'); - const pools = Array.isArray(config.pools) ? config.pools : []; - if (pools.length === 0) { - throw new Error(`Advancement ${fromIndex}→${toIndex} benötigt mindestens eine Regel.`); - } - - const seenPlaces = new Map(); - for (const [ruleIndex, rule] of pools.entries()) { - const fromPlaces = Array.isArray(rule?.fromPlaces) ? rule.fromPlaces.map(Number) : []; - if (fromPlaces.length === 0) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat keine Plätze.`); - } - if (!fromPlaces.every(place => Number.isInteger(place) && place > 0)) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat ungültige Plätze.`); - } - if (new Set(fromPlaces).size !== fromPlaces.length) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} enthält doppelte Plätze.`); - } - for (const place of fromPlaces) { - if (seenPlaces.has(place)) { - throw new Error(`Platz ${place} ist in ${fromIndex}→${toIndex} mehrfach vergeben.`); - } - seenPlaces.set(place, ruleIndex + 1); - } - - const target = rule?.target && typeof rule.target === 'object' ? rule.target : {}; - const targetType = target.type; - if (targetType !== toStage.type) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} passt nicht zur Zielrunde.`); - } - - const canEstimateQualifiers = fromStage.type === 'groups' && Number.isInteger(fromStage.numberOfGroups) && fromStage.numberOfGroups > 0; - const qualifierCount = canEstimateQualifiers ? fromStage.numberOfGroups * fromPlaces.length : null; - - if (toStage.type === 'groups') { - const targetGroupCount = Number(target.groupCount); - if (!Number.isInteger(targetGroupCount) || targetGroupCount < 1) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} benötigt Zielgruppen.`); - } - if (Number.isInteger(toStage.numberOfGroups) && toStage.numberOfGroups > 0 && targetGroupCount !== toStage.numberOfGroups) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat eine falsche Zielgruppenanzahl.`); - } - if (qualifierCount != null && qualifierCount < targetGroupCount) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} hat zu wenige Qualifizierte für die Zielgruppen.`); - } - } - - if (toStage.type === 'knockout' && qualifierCount != null && qualifierCount < 2) { - throw new Error(`Regel ${ruleIndex + 1} in ${fromIndex}→${toIndex} liefert zu wenige Qualifizierte für ein K.-o.-Feld.`); - } - } - } -} - -function isGroupAdvancementReady(group, groupMatches = [], requiredPlaces = []) { - const participants = Array.isArray(group?.participants) ? group.participants : []; - if (participants.length < 2) { - return true; - } - const matchesForGroup = groupMatches.filter(match => Number(match.groupId) === Number(group.groupId ?? group.id)); - if (matchesForGroup.length === 0 || matchesForGroup.some(match => !match.isFinished)) { - return false; - } - const positions = participants - .map(participant => Number(participant.position || 0)) - .filter(position => Number.isInteger(position) && position > 0); - if (positions.length !== participants.length) { - return false; - } - if (new Set(positions).size !== positions.length) { - return false; - } - if (requiredPlaces.length > 0) { - const maxRequiredPlace = Math.max(...requiredPlaces); - if (positions.some(position => position <= 0) || participants.length < maxRequiredPlace) { - return false; - } - for (let place = 1; place <= maxRequiredPlace; place++) { - if (!positions.includes(place)) { - return false; - } - } - } - return true; -} function getRoundName(size) { switch (size) { case 2: return "Finale"; @@ -224,16 +80,6 @@ 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)); @@ -248,226 +94,8 @@ function nextPowerOfTwo(n) { return p; } -function compareAdvancementCandidates(a, b) { - const posA = Number(a.position || a.place || 999); - const posB = Number(b.position || b.place || 999); - if (posA !== posB) return posA - posB; - - const pointsA = Number(a.points || 0); - const pointsB = Number(b.points || 0); - if (pointsB !== pointsA) return pointsB - pointsA; - - const setDiffA = Number(a.setDiff || 0); - const setDiffB = Number(b.setDiff || 0); - if (setDiffB !== setDiffA) return setDiffB - setDiffA; - - const setsWonA = Number(a.setsWon || 0); - const setsWonB = Number(b.setsWon || 0); - if (setsWonB !== setsWonA) return setsWonB - setsWonA; - - const pointsDiffA = Number(a.pointsDiff || 0); - const pointsDiffB = Number(b.pointsDiff || 0); - if (pointsDiffB !== pointsDiffA) return pointsDiffB - pointsDiffA; - - const pointsWonA = Number(a.pointsWon || 0); - const pointsWonB = Number(b.pointsWon || 0); - if (pointsWonB !== pointsWonA) return pointsWonB - pointsWonA; - - const groupNumberA = Number(a.groupNumber || 999); - const groupNumberB = Number(b.groupNumber || 999); - if (groupNumberA !== groupNumberB) return groupNumberA - groupNumberB; - - 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; -} - -function buildSeededLegacyKnockoutMatches(classQualifiers) { - const remaining = [...(classQualifiers || [])].sort(compareAdvancementCandidates); - const matches = []; - - while (remaining.length >= 2) { - const player1 = remaining.shift(); - if (!player1 || !player1.id) continue; - - let opponentIndex = remaining.length - 1; - while ( - opponentIndex >= 0 && - String(remaining[opponentIndex]?.groupId ?? '') === String(player1.groupId ?? '') - ) { - opponentIndex -= 1; - } - - if (opponentIndex < 0) { - opponentIndex = remaining.length - 1; - } - - const [player2] = remaining.splice(opponentIndex, 1); - if (!player2 || !player2.id || player1.id === player2.id) continue; - - matches.push({ player1, player2 }); - } - - return { - matches, - unusedQualifiers: remaining, - }; -} - const THIRD_PLACE_ROUND = 'Spiel um Platz 3'; class TournamentService { - /** - * Klassen in einem gemeinsamen Pool zusammenführen. - * strategy: 'singleGroup' (alle A-Spieler in eine neue Gruppe der Zielklasse) | - * 'distribute' (A-Spieler gleichmäßig auf Ziel-Gruppen verteilen) - */ - async mergeClassesIntoPool(userToken, clubId, tournamentId, sourceClassId, targetClassId, strategy = 'distribute', outOfCompetitionForSource = false) { - await checkAccess(userToken, clubId); - const tournament = await Tournament.findByPk(tournamentId); - if (!tournament || tournament.clubId != clubId) { - throw new Error('Turnier nicht gefunden'); - } - if (!sourceClassId || !targetClassId) { - throw new Error('sourceClassId und targetClassId sind erforderlich'); - } - if (String(sourceClassId) === String(targetClassId)) { - throw new Error('Quelle und Zielklasse dürfen nicht identisch sein'); - } - - // Erzeuge eine neue poolId (einfacher Ansatz) - const maxExisting = await TournamentGroup.max('poolId', { where: { tournamentId } }); - const poolId = Number.isFinite(Number(maxExisting)) ? Number(maxExisting) + 1 : 1; - - // Lade Ziel-Gruppen - const targetGroups = await TournamentGroup.findAll({ - where: { tournamentId, classId: targetClassId }, - order: [['id', 'ASC']] - }); - if (!targetGroups || targetGroups.length === 0) { - throw new Error('Zielklasse hat keine Gruppen. Bitte zuerst Gruppen anlegen.'); - } - - // Markiere Zielgruppen mit poolId - for (const g of targetGroups) { - g.poolId = poolId; - await g.save(); - } - - // Lade Teilnehmer der Quellklasse (intern + extern) - const sourceInternals = await TournamentMember.findAll({ where: { tournamentId, classId: sourceClassId } }); - const sourceExternals = await ExternalTournamentParticipant.findAll({ where: { tournamentId, classId: sourceClassId } }); - - // Optional: „außer Konkurrenz“ für Source markieren - if (outOfCompetitionForSource) { - await Promise.all([ - ...sourceInternals.map(m => { m.outOfCompetition = true; return m.save(); }), - ...sourceExternals.map(e => { e.outOfCompetition = true; return e.save(); }) - ]); - } - - if (strategy === 'singleGroup') { - // Lege eine zusätzliche Gruppe in der Zielklasse an, nur für Source-Spieler - // Bestimme groupNumber: max + 1 - const maxGroupNumber = await TournamentGroup.max('id', { where: { tournamentId, classId: targetClassId } }); - const newGroup = await TournamentGroup.create({ - stageId: null, - tournamentId, - classId: targetClassId, - poolId - }); - - // Weisen wir allen Source-Spielern diese neue Gruppe zu - for (const m of sourceInternals) { - m.groupId = newGroup.id; - m.classId = targetClassId; // bleiben sie in Quelle? Für Pool reicht Zielgruppen-Zuordnung; Klasse lassen wir unverändert - await m.save(); - } - for (const e of sourceExternals) { - e.groupId = newGroup.id; - e.classId = targetClassId; - await e.save(); - } - } else { - // distribute: Gleichmäßig über bestehende Zielgruppen verteilen - const cyclic = [...targetGroups]; - let idx = 0; - const assignNext = () => { - const g = cyclic[idx % cyclic.length]; - idx++; - return g; - }; - for (const m of sourceInternals) { - const g = assignNext(); - m.groupId = g.id; - // Klasse beim Teilnehmer unverändert lassen; Gruppenspiele laufen trotzdem im Pool - await m.save(); - } - for (const e of sourceExternals) { - const g = assignNext(); - e.groupId = g.id; - await e.save(); - } - } - - // Optional: vorhandene Gruppenspiele neu erstellen, damit die Verteilung greift - await this.createGroupMatches(userToken, clubId, tournamentId, null); - return { poolId }; - } - - /** - * Pool zurücksetzen: Entfernt poolId von Gruppen des Pools. - * (Vereinfachter Reset – Teilnehmer bleiben in Gruppen; Matches kann der Nutzer löschen.) - */ - async resetPool(userToken, clubId, tournamentId, poolId) { - await checkAccess(userToken, clubId); - if (!poolId) throw new Error('poolId ist erforderlich'); - const groups = await TournamentGroup.findAll({ where: { tournamentId, poolId } }); - for (const g of groups) { - g.poolId = null; - await g.save(); - } - } // -------- Multi-Stage (Runden) V1 -------- async getTournamentStages(userToken, clubId, tournamentId) { await checkAccess(userToken, clubId); @@ -523,8 +151,6 @@ class TournamentService { ? advancements : (advancement ? [advancement] : []); - validateStageAdvancements(stages, advList); - const createdAdvs = []; for (const adv of advList) { if (!adv || adv.fromStageIndex == null || adv.toStageIndex == null) continue; @@ -621,18 +247,9 @@ class TournamentService { const stage1Groups = await this.getGroupsWithParticipants(userToken, clubId, tournamentId); const relevantStage1Groups = stage1Groups.filter(g => (g.stageId == null) || (g.stageId === fromStage.id)); if (relevantStage1Groups.length === 0) throw new Error('Keine Gruppen in Runde 1 gefunden'); - const sourceGroupIds = relevantStage1Groups.map(group => Number(group.groupId ?? group.id)).filter(Number.isFinite); - const sourceGroupMatches = await TournamentMatch.findAll({ - where: { - tournamentId, - round: 'group', - groupId: sourceGroupIds - } - }); const perGroupRanked = relevantStage1Groups.map(g => ({ groupId: g.groupId, - groupNumber: g.groupNumber, classId: g.classId ?? null, // WICHTIG: // - Für interne Teilnehmer brauchen wir die ClubMember-ID (Member.id / TournamentMember.clubMemberId), @@ -641,14 +258,6 @@ class TournamentService { participants: (g.participants || []).map(p => ({ id: p.isExternal ? p.id : (p.clubMemberId ?? p.member?.id ?? p.id), isExternal: !!p.isExternal, - position: Number(p.position || 999), - points: Number(p.points || 0), - setDiff: Number(p.setDiff || 0), - setsWon: Number(p.setsWon || 0), - pointsDiff: Number(p.pointsDiff || 0), - pointsWon: Number(p.pointsWon || 0), - groupId: g.groupId, - groupNumber: g.groupNumber, })), })); @@ -676,15 +285,6 @@ class TournamentService { } const items = []; - const requiredPlaces = fromPlaces.map(place => Number(place)).filter(Number.isInteger); - const unreadyGroups = relevantStage1Groups.filter(group => - (group.classId ?? null) === classId && - !isGroupAdvancementReady(group, sourceGroupMatches, requiredPlaces) - ); - if (unreadyGroups.length > 0) { - const label = classId == null ? 'ohne Klasse' : `Klasse ${classId}`; - throw new Error(`Runde ${fromStage.index} ist für ${label} noch nicht startklar. Es fehlen fertige Gruppenspiele oder eindeutige Platzierungen.`); - } for (const grp of perGroupRanked) { if ((grp.classId ?? null) !== classId) continue; for (const place of fromPlaces) { @@ -869,40 +469,16 @@ class TournamentService { } } const uniqueEntrants = Array.from(seen.values()); - const selectedKeys = new Set(uniqueEntrants.map(entry => `${entry.isExternal ? 'E' : 'M'}:${entry.id}`)); - - const allRankedCandidates = perGroupRanked - .filter(group => (group.classId ?? null) === (classId ?? null)) - .flatMap(group => (group.participants || []).map(participant => ({ - id: Number(participant.id), - isExternal: !!participant.isExternal, - classId, - position: Number(participant.position || 999), - points: Number(participant.points || 0), - setDiff: Number(participant.setDiff || 0), - setsWon: Number(participant.setsWon || 0), - pointsDiff: Number(participant.pointsDiff || 0), - pointsWon: Number(participant.pointsWon || 0), - groupId: group.groupId, - groupNumber: group.groupNumber, - }))); - - const desiredBracketSize = nextPowerOfTwo(uniqueEntrants.length); - if (desiredBracketSize > uniqueEntrants.length) { - const bestAdditionalCandidates = allRankedCandidates - .filter(candidate => !selectedKeys.has(`${candidate.isExternal ? 'E' : 'M'}:${candidate.id}`)) - .sort(compareAdvancementCandidates) - .slice(0, desiredBracketSize - uniqueEntrants.length); - - bestAdditionalCandidates.forEach(candidate => { - selectedKeys.add(`${candidate.isExternal ? 'E' : 'M'}:${candidate.id}`); - uniqueEntrants.push(candidate); - }); - } const thirdPlace = wantsThirdPlace; if (uniqueEntrants.length >= 2) { - uniqueEntrants.sort(compareAdvancementCandidates); + // Sortiere nach Platz: beste Plätze zuerst, dann schlechtere + // Wenn mehrere Teilnehmer den gleichen Platz haben, behalte die ursprüngliche Reihenfolge + uniqueEntrants.sort((a, b) => { + const placeA = a.place || 999; + const placeB = b.place || 999; + return placeA - placeB; + }); // Paare: Bester gegen Schlechtesten, Zweiter gegen Vorletzten, etc. // Reverse die zweite Hälfte, um das gewünschte Pairing zu erreichen @@ -991,26 +567,24 @@ class TournamentService { }; } - // 1. Turniere listen (type: 'internal' | 'external' | 'mini' optional – bei 'mini' nur Minimeisterschaften) - async getTournaments(userToken, clubId, type = null) { + // 1. Turniere listen + async getTournaments(userToken, clubId) { await checkAccess(userToken, clubId); - const where = { clubId }; - if (type === 'mini') { - where.miniChampionshipYear = { [Op.ne]: null }; - } else if (type === 'internal' || type === 'external') { - where.miniChampionshipYear = { [Op.is]: null }; - } const tournaments = await Tournament.findAll({ - where, + where: { clubId }, order: [['date', 'DESC']], - attributes: ['id', 'name', 'date', 'allowsExternal', 'miniChampionshipYear', 'isDoublesTournament'] + attributes: ['id', 'name', 'date', 'allowsExternal'] }); return JSON.parse(JSON.stringify(tournaments)); } - // 2. Neues Turnier anlegen - async addTournament(userToken, clubId, tournamentName, date, winningSets, allowsExternal, isDoublesTournament = false) { + // 2. Neues Turnier anlegen (prüft Duplikat) + async addTournament(userToken, clubId, tournamentName, date, winningSets, allowsExternal) { await checkAccess(userToken, clubId); + const existing = await Tournament.findOne({ where: { clubId, date } }); + if (existing) { + throw new Error('Ein Turnier mit diesem Datum existiert bereits'); + } const t = await Tournament.create({ name: tournamentName, date, @@ -1018,78 +592,11 @@ class TournamentService { bestOfEndroundSize: 0, type: '', winningSets: winningSets || 3, // Default: 3 Sätze - allowsExternal: allowsExternal || false, - isDoublesTournament: Boolean(isDoublesTournament) + allowsExternal: allowsExternal || false }); return JSON.parse(JSON.stringify(t)); } - /** - * Minimeisterschaft anlegen: Turnier + 6 vorkonfigurierte Klassen (Jungen/Mädchen 12, 10, 8). - * Name wird generiert: "Minimeisterschaften Ortsentscheid ". - * Jahr Y: 12 = in Y 12 oder 13 Jahre (Geburtsjahr Y-13 oder Y-12), 10 = 10/11 (Y-11, Y-10), 8 = 9 und jünger (≥ Y-9). - * Standard-Gewinnsätze: 1. - */ - async addMiniChampionship(userToken, clubId, ort, date, year, winningSets = 1) { - await checkAccess(userToken, clubId); - const ortTrimmed = (ort || '').trim(); - if (!ortTrimmed) { - throw new Error('Ort für die Minimeisterschaft fehlt'); - } - const Y = Number(year); - if (!Number.isFinite(Y) || Y < 2000 || Y > 2100) { - throw new Error('Ungültiges Jahr für die Minimeisterschaft'); - } - const tournamentName = `Minimeisterschaften ${Y} Ortsentscheid ${ortTrimmed}`; - const sets = (winningSets != null && winningSets >= 1) ? Number(winningSets) : 1; - - const transaction = await Tournament.sequelize.transaction(); - try { - const t = await Tournament.create({ - name: tournamentName, - date, - clubId: +clubId, - type: 'groups', - numberOfGroups: 1, - advancingPerGroup: 1, - winningSets: sets, - allowsExternal: true, - miniChampionshipYear: Y - }, { transaction }); - - const classes = [ - { name: 'Jungen 12', gender: 'male', minBirthYear: Y - 13, maxBirthYear: Y - 12 }, - { name: 'Jungen 10', gender: 'male', minBirthYear: Y - 11, maxBirthYear: Y - 10 }, - { name: 'Jungen 8', gender: 'male', minBirthYear: Y - 9, maxBirthYear: null }, - { name: 'Mädchen 12', gender: 'female', minBirthYear: Y - 13, maxBirthYear: Y - 12 }, - { name: 'Mädchen 10', gender: 'female', minBirthYear: Y - 11, maxBirthYear: Y - 10 }, - { name: 'Mädchen 8', gender: 'female', minBirthYear: Y - 9, maxBirthYear: null }, - ]; - for (let i = 0; i < classes.length; i++) { - await TournamentClass.create({ - tournamentId: t.id, - name: classes[i].name, - sortOrder: i + 1, - isDoubles: false, - gender: classes[i].gender, - minBirthYear: classes[i].minBirthYear, - maxBirthYear: classes[i].maxBirthYear - }, { transaction }); - } - await transaction.commit(); - return JSON.parse(JSON.stringify(await Tournament.findByPk(t.id, { - attributes: ['id', 'name', 'date', 'allowsExternal', 'miniChampionshipYear'] - }))); - } catch (err) { - await transaction.rollback(); - const msg = err?.message || String(err); - if (msg.includes('max_birth_year') || msg.includes('Unknown column')) { - throw new Error('Datenbank-Migration für Minimeisterschaften fehlt. Bitte die Migration 20260130_add_mini_championship_and_max_birth_year.sql ausführen.'); - } - throw err; - } - } - // 3. Teilnehmer hinzufügen (kein Duplikat) - klassengebunden async addParticipant(userToken, clubId, classId, participantId, tournamentId = null) { await checkAccess(userToken, clubId); @@ -1138,8 +645,9 @@ class TournamentService { } } - // Validierung: Geburtsjahr muss zur Klasse passen (minBirthYear <= birthYear <= maxBirthYear) - if (member.birthDate) { + // Validierung: Geburtsjahr muss zur Klasse passen (geboren im Jahr X oder später, also >=) + if (tournamentClass.minBirthYear && member.birthDate) { + // Parse das Geburtsdatum (Format: YYYY-MM-DD oder DD.MM.YYYY) let birthYear = null; if (member.birthDate.includes('-')) { birthYear = parseInt(member.birthDate.split('-')[0]); @@ -1149,13 +657,10 @@ class TournamentService { birthYear = parseInt(parts[2]); } } - if (birthYear != null && !isNaN(birthYear)) { - if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { + if (birthYear && !isNaN(birthYear)) { + if (birthYear < tournamentClass.minBirthYear) { throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); } - if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); - } } } @@ -1966,7 +1471,6 @@ class TournamentService { if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } - const winningSets = tournament.winningSets || 3; let groups = await TournamentGroup.findAll({ where: { tournamentId }, include: [{ @@ -2040,30 +1544,12 @@ class TournamentService { } classGroups.forEach((g, idx) => { - // Bei zusammengeführten Klassen (Pool): Teilnehmer-classId pro ID - const participantIdToClassId = {}; - for (const tm of g.tournamentGroupMembers || []) { - participantIdToClassId[tm.id] = tm.classId ?? g.classId; - } - for (const ext of g.externalGroupMembers || []) { - participantIdToClassId[ext.id] = ext.classId ?? g.classId; - } - const classIdsInGroup = [...new Set(Object.values(participantIdToClassId).filter(c => c != null))]; - const isPool = !!g.poolId || classIdsInGroup.length > 1; - const participantClassIds = isPool && classIdsInGroup.length > 0 ? classIdsInGroup : [g.classId]; - - participantClassIds.forEach((participantClassId) => { + // Berechne Rankings für diese Gruppe const stats = {}; - const effectiveClassId = isPool ? participantClassId : g.classId; - + if (isDoubles && pairingsByGroup[g.id]) { - // Bei Doppel: Verwende Paarungen (bei Pool nur Paarungen dieser Klasse) + // Bei Doppel: Verwende Paarungen for (const pairing of pairingsByGroup[g.id]) { - if (isPool) { - const c1 = pairing.member1?.classId ?? pairing.external1?.classId ?? g.classId; - const c2 = pairing.member2?.classId ?? pairing.external2?.classId ?? g.classId; - if (c1 !== effectiveClassId || c2 !== effectiveClassId) continue; - } const player1Name = pairing.member1?.member ? `${pairing.member1.member.firstName} ${pairing.member1.member.lastName}` : pairing.external1 @@ -2075,7 +1561,6 @@ class TournamentService { ? `${pairing.external2.firstName} ${pairing.external2.lastName}` : 'Unbekannt'; - const pairingGaveUp = !!(pairing.member1?.gaveUp || pairing.member2?.gaveUp || pairing.external1?.gaveUp || pairing.external2?.gaveUp); stats[`pairing_${pairing.id}`] = { id: `pairing_${pairing.id}`, pairingId: pairing.id, @@ -2083,7 +1568,6 @@ class TournamentService { seeded: pairing.seeded || false, isExternal: false, isPairing: true, - gaveUp: pairingGaveUp, player1Id: pairing.member1Id || pairing.external1Id, player2Id: pairing.member2Id || pairing.external2Id, points: 0, @@ -2097,15 +1581,14 @@ class TournamentService { }; } } else { - // Bei Einzel: Verwende einzelne Spieler (bei Pool nur Teilnehmer dieser Klasse) + // Bei Einzel: Verwende einzelne Spieler + // Interne Teilnehmer for (const tm of g.tournamentGroupMembers || []) { - if (isPool && (tm.classId ?? g.classId) !== effectiveClassId) continue; stats[tm.id] = { id: tm.id, name: `${tm.member.firstName} ${tm.member.lastName}`, seeded: tm.seeded || false, isExternal: false, - gaveUp: !!tm.gaveUp, points: 0, setsWon: 0, setsLost: 0, @@ -2116,14 +1599,14 @@ class TournamentService { matchesLost: 0 }; } + + // Externe Teilnehmer for (const ext of g.externalGroupMembers || []) { - if (isPool && (ext.classId ?? g.classId) !== effectiveClassId) continue; stats[ext.id] = { id: ext.id, name: `${ext.firstName} ${ext.lastName}`, seeded: ext.seeded || false, isExternal: true, - gaveUp: !!ext.gaveUp, points: 0, setsWon: 0, setsLost: 0, @@ -2136,13 +1619,8 @@ class TournamentService { } } - // Berechne Statistiken aus Matches (bei Pool nur Spiele innerhalb derselben Klasse) + // Berechne Statistiken aus Matches for (const m of groupMatches.filter(m => m.groupId === g.id)) { - if (isPool) { - const c1 = participantIdToClassId[m.player1Id]; - const c2 = participantIdToClassId[m.player2Id]; - if (c1 !== effectiveClassId || c2 !== effectiveClassId) continue; - } if (isDoubles) { // Bei Doppel: Finde die Paarungen für player1Id und player2Id const pairing1Key = Object.keys(stats).find(key => @@ -2155,46 +1633,6 @@ class TournamentService { ); if (!pairing1Key || !pairing2Key) continue; - - const p1GaveUp = stats[pairing1Key].gaveUp; - const p2GaveUp = stats[pairing2Key].gaveUp; - if (p1GaveUp || p2GaveUp) { - if (p1GaveUp && p2GaveUp) { - stats[pairing1Key].setsWon += 0; - stats[pairing1Key].setsLost += 0; - stats[pairing2Key].setsWon += 0; - stats[pairing2Key].setsLost += 0; - } else if (p1GaveUp && !p2GaveUp) { - stats[pairing2Key].points += 1; - stats[pairing2Key].matchesWon += 1; - stats[pairing1Key].points -= 1; - stats[pairing1Key].matchesLost += 1; - stats[pairing1Key].setsWon += 0; - stats[pairing1Key].setsLost += winningSets; - stats[pairing2Key].setsWon += winningSets; - stats[pairing2Key].setsLost += 0; - const pts = 11 * winningSets; - stats[pairing1Key].pointsWon += 0; - stats[pairing1Key].pointsLost += pts; - stats[pairing2Key].pointsWon += pts; - stats[pairing2Key].pointsLost += 0; - } else { - stats[pairing1Key].points += 1; - stats[pairing1Key].matchesWon += 1; - stats[pairing2Key].points -= 1; - stats[pairing2Key].matchesLost += 1; - stats[pairing1Key].setsWon += winningSets; - stats[pairing1Key].setsLost += 0; - stats[pairing2Key].setsWon += 0; - stats[pairing2Key].setsLost += winningSets; - const pts = 11 * winningSets; - stats[pairing1Key].pointsWon += pts; - stats[pairing1Key].pointsLost += 0; - stats[pairing2Key].pointsWon += 0; - stats[pairing2Key].pointsLost += pts; - } - continue; - } // Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue; const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); @@ -2232,46 +1670,6 @@ class TournamentService { } else { // Bei Einzel: Normale Logik if (!stats[m.player1Id] || !stats[m.player2Id]) continue; - - const p1GaveUp = stats[m.player1Id].gaveUp; - const p2GaveUp = stats[m.player2Id].gaveUp; - if (p1GaveUp || p2GaveUp) { - if (p1GaveUp && p2GaveUp) { - stats[m.player1Id].setsWon += 0; - stats[m.player1Id].setsLost += 0; - stats[m.player2Id].setsWon += 0; - stats[m.player2Id].setsLost += 0; - } else if (p1GaveUp && !p2GaveUp) { - stats[m.player2Id].points += 1; - stats[m.player2Id].matchesWon += 1; - stats[m.player1Id].points -= 1; - stats[m.player1Id].matchesLost += 1; - stats[m.player1Id].setsWon += 0; - stats[m.player1Id].setsLost += winningSets; - stats[m.player2Id].setsWon += winningSets; - stats[m.player2Id].setsLost += 0; - const pts = 11 * winningSets; - stats[m.player1Id].pointsWon += 0; - stats[m.player1Id].pointsLost += pts; - stats[m.player2Id].pointsWon += pts; - stats[m.player2Id].pointsLost += 0; - } else { - stats[m.player1Id].points += 1; - stats[m.player1Id].matchesWon += 1; - stats[m.player2Id].points -= 1; - stats[m.player2Id].matchesLost += 1; - stats[m.player1Id].setsWon += winningSets; - stats[m.player1Id].setsLost += 0; - stats[m.player2Id].setsWon += 0; - stats[m.player2Id].setsLost += winningSets; - const pts = 11 * winningSets; - stats[m.player1Id].pointsWon += pts; - stats[m.player1Id].pointsLost += 0; - stats[m.player2Id].pointsWon += 0; - stats[m.player2Id].pointsLost += pts; - } - continue; - } // Ergebnis kann null/undefiniert oder in anderem Format sein -> defensiv prüfen if (!m.result || typeof m.result !== 'string' || !m.result.includes(':')) continue; const [s1, s2] = m.result.split(':').map(n => parseInt(n, 10)); @@ -2426,11 +1824,10 @@ class TournamentService { result.push({ groupId: g.id, - classId: effectiveClassId, + classId: g.classId, groupNumber: idx + 1, // Nummer innerhalb der Klasse participants: participantsWithPosition }); - }); // participantClassIds.forEach }); } @@ -2452,8 +1849,8 @@ class TournamentService { return JSON.parse(JSON.stringify(t)); } - // Update Turnier (Name, Datum, Gewinnsätze und Tischanzahl) - async updateTournament(userToken, clubId, tournamentId, name, date, winningSets, numberOfTables, isDoublesTournament) { + // Update Turnier (Name, Datum und Gewinnsätze) + async updateTournament(userToken, clubId, tournamentId, name, date, winningSets) { await checkAccess(userToken, clubId); const tournament = await Tournament.findOne({ where: { id: tournamentId, clubId } }); if (!tournament) { @@ -2476,15 +1873,6 @@ class TournamentService { } tournament.winningSets = winningSets; } - if (numberOfTables !== undefined) { - if (numberOfTables !== null && numberOfTables < 1) { - throw new Error('Anzahl der Tische muss mindestens 1 sein'); - } - tournament.numberOfTables = numberOfTables; - } - if (isDoublesTournament !== undefined) { - tournament.isDoublesTournament = Boolean(isDoublesTournament); - } await tournament.save(); return JSON.parse(JSON.stringify(tournament)); @@ -2578,8 +1966,7 @@ class TournamentService { lastName: ep.lastName, club: ep.club, gender: ep.gender, - birthDate: ep.birthDate, - gaveUp: !!ep.gaveUp + birthDate: ep.birthDate }; } else { plain.player1 = null; @@ -2597,49 +1984,18 @@ class TournamentService { lastName: ep.lastName, club: ep.club, gender: ep.gender, - birthDate: ep.birthDate, - gaveUp: !!ep.gaveUp + birthDate: ep.birthDate }; } else { plain.player2 = null; } - // Virtuelle Ergebnisse bei Aufgabe: ein Spieler aufgegeben → Gegner 11:0 je Satz; beide aufgegeben → 0:0, kein Sieger - const winningSets = t.winningSets || 3; - const p1GaveUp = plain.player1?.gaveUp; - const p2GaveUp = plain.player2?.gaveUp; - if (p1GaveUp || p2GaveUp) { - plain.gaveUpMatch = true; - if (p1GaveUp && p2GaveUp) { - plain.result = '0:0'; - plain.isFinished = true; - plain.tournamentResults = [{ set: 1, pointsPlayer1: 0, pointsPlayer2: 0 }]; - } else if (p1GaveUp && !p2GaveUp) { - plain.result = `0:${winningSets}`; - plain.isFinished = true; - plain.tournamentResults = Array.from({ length: winningSets }, (_, i) => ({ set: i + 1, pointsPlayer1: 0, pointsPlayer2: 11 })); - } else { - plain.result = `${winningSets}:0`; - plain.isFinished = true; - plain.tournamentResults = Array.from({ length: winningSets }, (_, i) => ({ set: i + 1, pointsPlayer1: 11, pointsPlayer2: 0 })); - } - } - return plain; }); return finalMatches; } - async _matchHasGaveUpPlayer(tournamentId, player1Id, player2Id) { - const ids = [player1Id, player2Id].filter(Boolean); - if (ids.length === 0) return false; - const members = await TournamentMember.findAll({ where: { tournamentId, id: { [Op.in]: ids } } }); - const externals = await ExternalTournamentParticipant.findAll({ where: { tournamentId, id: { [Op.in]: ids } } }); - if (members.some(m => m.gaveUp) || externals.some(e => e.gaveUp)) return true; - return false; - } - // 12. Satz-Ergebnis hinzufügen/überschreiben async addMatchResult(userToken, clubId, tournamentId, matchId, set, result) { await checkAccess(userToken, clubId); @@ -2652,9 +2008,6 @@ class TournamentService { // Lade Match, um isFinished zu prüfen const match = await TournamentMatch.findByPk(matchId); if (!match || match.tournamentId != tournamentId) throw new Error('Match nicht gefunden'); - if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) { - throw new Error('Ergebnisse von Spielen mit aufgegebenen Spielern können nicht bearbeitet werden.'); - } const existing = await TournamentResult.findOne({ where: { matchId, set } }); if (existing) { @@ -2699,9 +2052,6 @@ class TournamentService { include: [{ model: TournamentResult, as: "tournamentResults" }] }); if (!match) throw new Error("Match nicht gefunden"); - if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) { - throw new Error('Spiele mit aufgegebenen Spielern können nicht abgeschlossen werden.'); - } let win = 0, lose = 0; for (const r of match.tournamentResults) { @@ -2873,10 +2223,9 @@ class TournamentService { ] }); const groupMatches = await TournamentMatch.findAll({ - where: { tournamentId, round: "group" }, + where: { tournamentId, round: "group", isFinished: true }, include: [{ model: TournamentResult, as: "tournamentResults" }] }); - const winningSets = tournament.winningSets || 3; // Lade alle Klassen, um zu prüfen, ob es sich um Doppel-Klassen handelt const tournamentClasses = await TournamentClass.findAll({ where: { tournamentId } }); @@ -2885,64 +2234,51 @@ class TournamentService { return map; }, {}); - const rankedParticipantsByClass = new Map(); + const qualifiers = []; for (const g of groups) { const classId = g.classId; const isDoubles = classId ? (classIsDoublesMap[classId] || false) : false; const stats = {}; // Interne Teilnehmer for (const tm of g.tournamentGroupMembers || []) { - stats[tm.id] = { member: tm, gaveUp: !!tm.gaveUp, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false, matchesWon: 0, matchesLost: 0 }; + stats[tm.id] = { member: tm, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: false, matchesWon: 0, matchesLost: 0 }; } // Externe Teilnehmer for (const ext of g.externalGroupMembers || []) { - stats[ext.id] = { member: ext, gaveUp: !!ext.gaveUp, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true, matchesWon: 0, matchesLost: 0 }; + stats[ext.id] = { member: ext, points: 0, setsWon: 0, setsLost: 0, pointsWon: 0, pointsLost: 0, isExternal: true, matchesWon: 0, matchesLost: 0 }; } for (const m of groupMatches.filter(m => m.groupId === g.id)) { if (!stats[m.player1Id] || !stats[m.player2Id]) continue; - const p1GaveUp = stats[m.player1Id].gaveUp; - const p2GaveUp = stats[m.player2Id].gaveUp; - let p1, p2, p1Points = 0, p2Points = 0; - if (p1GaveUp || p2GaveUp) { - if (p1GaveUp && p2GaveUp) { - p1 = 0; p2 = 0; - } else if (p1GaveUp && !p2GaveUp) { - p1 = 0; p2 = winningSets; - p2Points = 11 * winningSets; - } else { - p1 = winningSets; p2 = 0; - p1Points = 11 * winningSets; - } - } else if (m.isFinished && m.result && typeof m.result === 'string' && m.result.includes(':')) { - [p1, p2] = m.result.split(":").map(n => parseInt(n, 10)); - if (m.tournamentResults && m.tournamentResults.length > 0) { - for (const r of m.tournamentResults) { - p1Points += Math.abs(r.pointsPlayer1 || 0); - p2Points += Math.abs(r.pointsPlayer2 || 0); - } - } - } else { - continue; - } + const [p1, p2] = m.result.split(":").map(n => parseInt(n, 10)); if (p1 > p2) { - stats[m.player1Id].points += 1; + stats[m.player1Id].points += 1; // Sieger bekommt +1 stats[m.player1Id].matchesWon += 1; - stats[m.player2Id].points -= 1; + stats[m.player2Id].points -= 1; // Verlierer bekommt -1 stats[m.player2Id].matchesLost += 1; - } else if (p2 > p1) { - stats[m.player2Id].points += 1; + } else { + stats[m.player2Id].points += 1; // Sieger bekommt +1 stats[m.player2Id].matchesWon += 1; - stats[m.player1Id].points -= 1; + stats[m.player1Id].points -= 1; // Verlierer bekommt -1 stats[m.player1Id].matchesLost += 1; } stats[m.player1Id].setsWon += p1; stats[m.player1Id].setsLost += p2; stats[m.player2Id].setsWon += p2; stats[m.player2Id].setsLost += p1; - stats[m.player1Id].pointsWon += p1Points; - stats[m.player1Id].pointsLost += p2Points; - stats[m.player2Id].pointsWon += p2Points; - stats[m.player2Id].pointsLost += p1Points; + + // Berechne gespielte Punkte aus tournamentResults + if (m.tournamentResults && m.tournamentResults.length > 0) { + let p1Points = 0, p2Points = 0; + for (const r of m.tournamentResults) { + // Verwende absolute Werte, falls negative Werte gespeichert wurden + p1Points += Math.abs(r.pointsPlayer1 || 0); + p2Points += Math.abs(r.pointsPlayer2 || 0); + } + stats[m.player1Id].pointsWon += p1Points; + stats[m.player1Id].pointsLost += p2Points; + stats[m.player2Id].pointsWon += p2Points; + stats[m.player2Id].pointsLost += p1Points; + } } // Berechne Punktverhältnis und absolute Differenz für jeden Spieler @@ -2966,86 +2302,38 @@ class TournamentService { // 5. Bei Spielpunktgleichheit: Wer mehr Spielpunkte erzielt hat if (b.pointsWon !== a.pointsWon) return b.pointsWon - a.pointsWon; // 6. Direkter Vergleich (Sieger weiter oben) - const directMatch = groupMatches.find(m => + const directMatch = groupMatches.find(m => m.groupId === g.id && ((m.player1Id === a.member.id && m.player2Id === b.member.id) || (m.player1Id === b.member.id && m.player2Id === a.member.id)) ); if (directMatch) { - const p1Gu = stats[directMatch.player1Id]?.gaveUp; - const p2Gu = stats[directMatch.player2Id]?.gaveUp; - let s1, s2; - if (p1Gu && p2Gu) { - s1 = 0; s2 = 0; - } else if (p1Gu && !p2Gu) { - s1 = 0; s2 = winningSets; - } else if (!p1Gu && p2Gu) { - s1 = winningSets; s2 = 0; - } else if (directMatch.result && typeof directMatch.result === 'string' && directMatch.result.includes(':')) { - [s1, s2] = directMatch.result.split(":").map(n => parseInt(n, 10)); - } else { - return a.member.id - b.member.id; - } - const aWon = (directMatch.player1Id === a.member.id && s1 > s2) || + const [s1, s2] = directMatch.result.split(":").map(n => parseInt(n, 10)); + const aWon = (directMatch.player1Id === a.member.id && s1 > s2) || (directMatch.player2Id === a.member.id && s2 > s1); - if (aWon) return -1; - return 1; + if (aWon) return -1; // a hat gewonnen -> a kommt weiter oben + return 1; // b hat gewonnen -> b kommt weiter oben } // Fallback: Nach ID return a.member.id - b.member.id; }); - const classKey = classId == null ? 'null' : String(classId); - const groupNumber = Number(g.number || g.groupNumber || g.sortOrder || g.id || 999); - const rankedParticipants = ranked.map((r, position) => { + // Füge classId und groupId zur Gruppe hinzu + // r.member ist entweder TournamentMember oder ExternalTournamentParticipant + qualifiers.push(...ranked.slice(0, tournament.advancingPerGroup).map((r, position) => { const member = r.member; + // Stelle sicher, dass id vorhanden ist if (!member || !member.id) { devLog(`[_determineQualifiers] Warning: Member without id found in group ${g.id}`); return null; } return { id: member.id, - classId, + classId: g.classId, groupId: g.id, isExternal: r.isExternal || false, - position: position + 1, - points: Number(r.points || 0), - setDiff: Number((r.setsWon || 0) - (r.setsLost || 0)), - setsWon: Number(r.setsWon || 0), - pointsDiff: Number(r.pointsDiff || 0), - pointsWon: Number(r.pointsWon || 0), - groupNumber, + position: position + 1 // 1-basierte Position innerhalb der Gruppe (1., 2., 3., etc.) }; - }).filter(q => q !== null); - - if (!rankedParticipantsByClass.has(classKey)) { - rankedParticipantsByClass.set(classKey, []); - } - rankedParticipantsByClass.get(classKey).push(...rankedParticipants); - } - - const qualifiers = []; - for (const [classKey, rankedParticipants] of rankedParticipantsByClass.entries()) { - const explicitQualifiers = rankedParticipants - .filter(participant => Number(participant.position || 999) <= Number(tournament.advancingPerGroup || 0)) - .sort(compareAdvancementCandidates); - - const selectedKeys = new Set(explicitQualifiers.map(participant => `${participant.isExternal ? 'E' : 'M'}:${participant.id}`)); - const desiredBracketSize = nextPowerOfTwo(explicitQualifiers.length); - - if (desiredBracketSize > explicitQualifiers.length) { - const additionalQualifiers = rankedParticipants - .filter(participant => !selectedKeys.has(`${participant.isExternal ? 'E' : 'M'}:${participant.id}`)) - .sort(compareAdvancementCandidates) - .slice(0, desiredBracketSize - explicitQualifiers.length); - - additionalQualifiers.forEach(participant => { - selectedKeys.add(`${participant.isExternal ? 'E' : 'M'}:${participant.id}`); - explicitQualifiers.push(participant); - }); - } - - explicitQualifiers.sort(compareAdvancementCandidates); - qualifiers.push(...explicitQualifiers); + }).filter(q => q !== null)); } return qualifiers; } @@ -3152,83 +2440,77 @@ class TournamentService { 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; - let matches = null; - let unusedQualifiers = []; - - const seededResult = buildSeededLegacyKnockoutMatches(classQualifiers); - if (seededResult.matches.length > 0) { - matches = seededResult.matches; - unusedQualifiers = seededResult.unusedQualifiers; - } else { - 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 => { + + // 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 + const groupQualifiers = qualifiersByGroup[groupKey]; - const qualifierAtPos = groupQualifiers.find(q => q.position === pos); - if (qualifierAtPos && !usedQualifiers.has(qualifierAtPos.id)) { - qualifiersAtPosition.push(qualifierAtPos); + const opponentCandidate = groupQualifiers.find(q => + q.position === opponentPosition && !usedQualifiers.has(q.id) + ); + + if (opponentCandidate) { + opponent = opponentCandidate; + break; } - }); - - const opponentPosition = advancingPerGroup - pos + 1; - - qualifiersAtPosition.forEach(qualifier => { - let opponent = null; + } + + // Falls kein Gegner gefunden, suche nach einem beliebigen Gegner aus einer anderen Gruppe + if (!opponent) { for (const groupKey of groups) { if (groupKey === qualifier.groupId.toString()) continue; - + const groupQualifiers = qualifiersByGroup[groupKey]; - const opponentCandidate = groupQualifiers.find(q => - q.position === opponentPosition && !usedQualifiers.has(q.id) - ); - + const opponentCandidate = groupQualifiers.find(q => !usedQualifiers.has(q.id)); + if (opponentCandidate) { opponent = opponentCandidate; break; } } - - 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); - } - }); - } - - unusedQualifiers = classQualifiers.filter(q => q && q.id && !usedQualifiers.has(q.id)); + } + + 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)); if (unusedQualifiers.length > 0) { devLog(`[startKnockout] Assigning ${unusedQualifiers.length} bye(s) for class ${classKey}:`, unusedQualifiers.map(q => q.id)); } @@ -3485,67 +2767,11 @@ class TournamentService { ); } - // Fehlende Gruppenspiele für den neuen Teilnehmer erstellen (nur Einzel) - if (dbGroupId != null) { - const isDoubles = participantClassId != null - ? (await TournamentClass.findOne({ where: { id: participantClassId, tournamentId } }))?.isDoubles - : false; - if (!isDoubles) { - await this.addMissingGroupMatchesForNewParticipant(tournamentId, dbGroupId, participantClassId, participantId, isExternal); - } - } - console.log(`[assignParticipantToGroup] Successfully updated participant, loading groups with participants...`); + // Lade aktualisierte Gruppen mit Teilnehmern zurück return await this.getGroupsWithParticipants(userToken, clubId, tournamentId); } - /** - * Erstellt fehlende Gruppenspiele, wenn ein neuer Spieler zu einer Gruppe hinzugefügt wird, - * die bereits Spiele hat. Einzel-Klassen nur. - */ - async addMissingGroupMatchesForNewParticipant(tournamentId, groupId, classId, newParticipantId, isExternal) { - const internalMembers = await TournamentMember.findAll({ where: { groupId } }); - const externalMembers = await ExternalTournamentParticipant.findAll({ where: { groupId } }); - const allMembers = [ - ...internalMembers.map(m => ({ id: m.id, key: `i-${m.id}` })), - ...externalMembers.map(m => ({ id: m.id, key: `e-${m.id}` })) - ]; - if (allMembers.length < 2) return; - - const matchWhere = { tournamentId, groupId, round: 'group' }; - if (classId != null) matchWhere.classId = classId; - else matchWhere.classId = { [Op.is]: null }; - const existingMatches = await TournamentMatch.findAll({ where: matchWhere }); - const pairKey = (a, b) => [a, b].sort((x, y) => x - y).join('-'); - const existingPairs = new Set(); - existingMatches.forEach(m => { - if (m.player1Id && m.player2Id) { - existingPairs.add(pairKey(m.player1Id, m.player2Id)); - } - }); - - const maxRound = existingMatches.reduce((max, m) => Math.max(max, m.groupRound || 0), 0); - let nextRound = maxRound + 1; - - const newMemberKey = isExternal ? `e-${newParticipantId}` : `i-${newParticipantId}`; - for (const other of allMembers) { - if (other.id === newParticipantId) continue; - const key = pairKey(newParticipantId, other.id); - if (existingPairs.has(key)) continue; - - await TournamentMatch.create({ - tournamentId, - groupId, - round: 'group', - player1Id: newParticipantId, - player2Id: other.id, - groupRound: nextRound++, - classId - }); - existingPairs.add(key); - } - } - // services/tournamentService.js async resetGroups(userToken, clubId, tournamentId) { await checkAccess(userToken, clubId); @@ -3565,38 +2791,6 @@ class TournamentService { await TournamentMatch.destroy({ where }); } - /** - * Entfernt Matches, bei denen mindestens ein Spieler nicht mehr existiert - * (z.B. gelöscht bevor die Aufräum-Logik beim Teilnehmerlöschen eingeführt wurde). - */ - async cleanupOrphanedMatches(userToken, clubId, tournamentId) { - await checkAccess(userToken, clubId); - const tournament = await Tournament.findByPk(tournamentId); - if (!tournament || tournament.clubId != clubId) { - throw new Error('Turnier nicht gefunden'); - } - - const members = await TournamentMember.findAll({ where: { tournamentId }, attributes: ['id'] }); - const externals = await ExternalTournamentParticipant.findAll({ where: { tournamentId }, attributes: ['id'] }); - const validIds = new Set([ - ...members.map(m => m.id), - ...externals.map(e => e.id) - ]); - - const matches = await TournamentMatch.findAll({ where: { tournamentId } }); - let deletedCount = 0; - for (const m of matches) { - const p1Exists = !m.player1Id || validIds.has(m.player1Id); - const p2Exists = !m.player2Id || validIds.has(m.player2Id); - if (!p1Exists || !p2Exists) { - await TournamentResult.destroy({ where: { matchId: m.id } }); - await m.destroy(); - deletedCount++; - } - } - return { deletedCount }; - } - async removeParticipant(userToken, clubId, tournamentId, participantId) { await checkAccess(userToken, clubId); @@ -3637,35 +2831,13 @@ class TournamentService { await participant.save(); } - async setParticipantGaveUp(userToken, clubId, tournamentId, participantId, gaveUp) { - await checkAccess(userToken, clubId); - const participant = await TournamentMember.findOne({ - where: { id: participantId, tournamentId } - }); - if (!participant) throw new Error('Teilnehmer nicht gefunden'); - participant.gaveUp = !!gaveUp; - await participant.save(); - } - - async setExternalParticipantGaveUp(userToken, clubId, tournamentId, participantId, gaveUp) { - await checkAccess(userToken, clubId); - const participant = await ExternalTournamentParticipant.findOne({ - where: { id: participantId, tournamentId } - }); - if (!participant) throw new Error('Externer Teilnehmer nicht gefunden'); - participant.gaveUp = !!gaveUp; - await participant.save(); - } - // services/tournamentService.js async deleteMatchResult(userToken, clubId, tournamentId, matchId, setToDelete) { await checkAccess(userToken, clubId); + // Match existiert? const match = await TournamentMatch.findOne({ where: { id: matchId, tournamentId } }); if (!match) throw new Error('Match nicht gefunden'); - if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) { - throw new Error('Ergebnisse von Spielen mit aufgegebenen Spielern können nicht bearbeitet werden.'); - } // Satz löschen await TournamentResult.destroy({ where: { matchId, set: setToDelete } }); @@ -3694,47 +2866,9 @@ class TournamentService { if (!match) { throw new Error("Match nicht gefunden"); } - if (await this._matchHasGaveUpPlayer(tournamentId, match.player1Id, match.player2Id)) { - 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(); } @@ -3754,20 +2888,6 @@ class TournamentService { await match.save(); } - async setMatchTableNumber(userToken, clubId, tournamentId, matchId, tableNumber) { - await checkAccess(userToken, clubId); - - const match = await TournamentMatch.findOne({ - where: { id: matchId, tournamentId } - }); - if (!match) { - throw new Error("Match nicht gefunden"); - } - - match.tableNumber = tableNumber != null && tableNumber !== '' ? Number(tableNumber) : null; - await match.save(); - } - async resetKnockout(userToken, clubId, tournamentId, classId = null) { await checkAccess(userToken, clubId); // lösche alle Matches außer Gruppenphase @@ -3782,7 +2902,7 @@ class TournamentService { } // Externe Teilnehmer hinzufügen - async addExternalParticipant(userToken, clubId, classId, firstName, lastName, club, birthDate, gender, email = null, address = null) { + async addExternalParticipant(userToken, clubId, classId, firstName, lastName, club, birthDate, gender) { await checkAccess(userToken, clubId); if (!classId) { throw new Error('Klasse ist erforderlich'); @@ -3829,13 +2949,11 @@ class TournamentService { } } - if (birthYear != null && !isNaN(birthYear)) { - if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { + if (birthYear && !isNaN(birthYear)) { + // Geboren im Jahr X oder später bedeutet: birthYear >= minBirthYear + if (birthYear < tournamentClass.minBirthYear) { throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); } - if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); - } } } @@ -3845,8 +2963,6 @@ class TournamentService { firstName, lastName, club: club || null, - email: email || null, - address: address || null, birthDate: birthDate || null, gender: participantGender, groupId: null @@ -3883,16 +2999,6 @@ class TournamentService { if (!participant) { throw new Error('Externer Teilnehmer nicht gefunden'); } - // Lösche alle Matches, in denen dieser Teilnehmer spielt - await TournamentMatch.destroy({ - where: { - tournamentId, - [Op.or]: [ - { player1Id: participantId }, - { player2Id: participantId } - ] - } - }); await participant.destroy(); } @@ -3922,12 +3028,13 @@ class TournamentService { }); } - async addTournamentClass(userToken, clubId, tournamentId, name, isDoubles = false, gender = null, minBirthYear = null, maxBirthYear = null) { + async addTournamentClass(userToken, clubId, tournamentId, name, isDoubles = false, gender = null, minBirthYear = null) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } + // Finde die höchste sortOrder const maxSortOrder = await TournamentClass.max('sortOrder', { where: { tournamentId } }) || 0; @@ -3937,12 +3044,11 @@ class TournamentService { sortOrder: maxSortOrder + 1, isDoubles: isDoubles || false, gender: gender || null, - minBirthYear: minBirthYear ?? null, - maxBirthYear: maxBirthYear ?? null + minBirthYear: minBirthYear || null }); } - async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear, maxBirthYear) { + async updateTournamentClass(userToken, clubId, tournamentId, classId, name, sortOrder, isDoubles, gender, minBirthYear) { await checkAccess(userToken, clubId); const tournament = await Tournament.findByPk(tournamentId); if (!tournament || tournament.clubId != clubId) { @@ -3954,15 +3060,37 @@ class TournamentService { if (!tournamentClass) { throw new Error('Klasse nicht gefunden'); } + console.log('[updateTournamentClass] Before update:', { + id: tournamentClass.id, + name: tournamentClass.name, + isDoubles: tournamentClass.isDoubles, + gender: tournamentClass.gender, + minBirthYear: tournamentClass.minBirthYear + }); + console.log('[updateTournamentClass] New values:', { name, sortOrder, isDoubles, gender, minBirthYear }); + + // Verwende update() statt direkter Zuweisung für bessere Kontrolle const updateData = {}; if (name !== undefined) updateData.name = name; if (sortOrder !== undefined) updateData.sortOrder = sortOrder; if (isDoubles !== undefined) updateData.isDoubles = isDoubles; if (gender !== undefined) updateData.gender = gender; if (minBirthYear !== undefined) updateData.minBirthYear = minBirthYear; - if (maxBirthYear !== undefined) updateData.maxBirthYear = maxBirthYear; + + console.log('[updateTournamentClass] Update data:', updateData); + await tournamentClass.update(updateData); + + // Lade die aktualisierte Instanz neu, um sicherzustellen, dass wir die aktuellen DB-Werte haben await tournamentClass.reload(); + + console.log('[updateTournamentClass] After update and reload:', { + id: tournamentClass.id, + name: tournamentClass.name, + isDoubles: tournamentClass.isDoubles, + gender: tournamentClass.gender, + minBirthYear: tournamentClass.minBirthYear + }); return tournamentClass; } @@ -3996,9 +3124,8 @@ class TournamentService { if (!tournament || tournament.clubId != clubId) { throw new Error('Turnier nicht gefunden'); } - let tournamentClass = null; if (classId !== null) { - tournamentClass = await TournamentClass.findOne({ + const tournamentClass = await TournamentClass.findOne({ where: { id: classId, tournamentId } }); if (!tournamentClass) { @@ -4012,39 +3139,6 @@ class TournamentService { if (!participant) { throw new Error('Externer Teilnehmer nicht gefunden'); } - if (tournamentClass) { - const participantGender = participant.gender || 'unknown'; - if (tournamentClass.gender) { - if (tournamentClass.gender === 'male' && participantGender !== 'male') { - throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); - } - if (tournamentClass.gender === 'female' && participantGender !== 'female') { - throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); - } - if (tournamentClass.gender === 'mixed' && participantGender === 'unknown') { - throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); - } - } - if ((tournamentClass.minBirthYear != null || tournamentClass.maxBirthYear != null) && participant.birthDate) { - let birthYear = null; - if (participant.birthDate.includes('-')) { - birthYear = parseInt(participant.birthDate.split('-')[0]); - } else if (participant.birthDate.includes('.')) { - const parts = participant.birthDate.split('.'); - if (parts.length === 3) { - birthYear = parseInt(parts[2]); - } - } - if (birthYear != null && !isNaN(birthYear)) { - if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); - } - if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); - } - } - } - } participant.classId = classId; await participant.save(); } else { @@ -4054,43 +3148,6 @@ class TournamentService { if (!participant) { throw new Error('Teilnehmer nicht gefunden'); } - if (tournamentClass) { - const member = await Member.findByPk(participant.clubMemberId); - if (!member) { - throw new Error('Mitglied nicht gefunden'); - } - const memberGender = member.gender || 'unknown'; - if (tournamentClass.gender) { - if (tournamentClass.gender === 'male' && memberGender !== 'male') { - throw new Error('Dieser Teilnehmer kann nicht in einer männlichen Klasse spielen'); - } - if (tournamentClass.gender === 'female' && memberGender !== 'female') { - throw new Error('Dieser Teilnehmer kann nicht in einer weiblichen Klasse spielen'); - } - if (tournamentClass.gender === 'mixed' && memberGender === 'unknown') { - throw new Error('Teilnehmer mit unbekanntem Geschlecht können nicht in einer Mixed-Klasse spielen'); - } - } - if ((tournamentClass.minBirthYear != null || tournamentClass.maxBirthYear != null) && member.birthDate) { - let birthYear = null; - if (member.birthDate.includes('-')) { - birthYear = parseInt(member.birthDate.split('-')[0]); - } else if (member.birthDate.includes('.')) { - const parts = member.birthDate.split('.'); - if (parts.length === 3) { - birthYear = parseInt(parts[2]); - } - } - if (birthYear != null && !isNaN(birthYear)) { - if (tournamentClass.minBirthYear != null && birthYear < tournamentClass.minBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu alt für diese Klasse. Erlaubt: geboren ${tournamentClass.minBirthYear} oder später`); - } - if (tournamentClass.maxBirthYear != null && birthYear > tournamentClass.maxBirthYear) { - throw new Error(`Dieser Teilnehmer ist zu jung für diese Klasse. Erlaubt: geboren ${tournamentClass.maxBirthYear} oder früher`); - } - } - } - } participant.classId = classId; await participant.save(); } @@ -4196,22 +3253,6 @@ class TournamentService { if (existingPairing) { throw new Error('Diese Paarung existiert bereits'); } - const playerAssignments = []; - if (player1Type === 'member') playerAssignments.push({ member1Id: player1Id }, { member2Id: player1Id }); - if (player1Type === 'external') playerAssignments.push({ external1Id: player1Id }, { external2Id: player1Id }); - if (player2Type === 'member') playerAssignments.push({ member1Id: player2Id }, { member2Id: player2Id }); - if (player2Type === 'external') playerAssignments.push({ external1Id: player2Id }, { external2Id: player2Id }); - - const participantAlreadyAssigned = await TournamentPairing.findOne({ - where: { - tournamentId, - classId, - [Op.or]: playerAssignments - } - }); - if (participantAlreadyAssigned) { - throw new Error('Mindestens ein Spieler ist bereits einer Doppelpaarung zugeordnet'); - } return await TournamentPairing.create({ tournamentId, classId, @@ -4300,136 +3341,6 @@ class TournamentService { await pairing.destroy(); } - /** - * Ranglisten für interne Einzel-Turniere (Punkte nach Gruppenplatz + K.-o.) über einen Zeitraum. - */ - async getInternalTournamentPlayerStats(userToken, clubId, months = 12, ageClassKeysQuery = null) { - await checkAccess(userToken, clubId); - const m = Number(months); - const monthsNum = [3, 6, 12].includes(m) ? m : 12; - const from = new Date(); - from.setMonth(from.getMonth() - monthsNum); - const fromStr = from.toISOString().slice(0, 10); - - const tournaments = await Tournament.findAll({ - where: { - clubId: +clubId, - allowsExternal: false, - miniChampionshipYear: { [Op.is]: null }, - date: { [Op.gte]: fromStr }, - }, - attributes: ['id', 'name', 'date'], - order: [['date', 'DESC']], - }); - const list = JSON.parse(JSON.stringify(tournaments)); - - const memberAgg = new Map(); - - const rawKeys = parseInternalStatsAgeClassKeys(ageClassKeysQuery); - - for (const t of list) { - const classes = await this.getTournamentClasses(userToken, clubId, t.id); - const classesJson = classes.map((c) => (c.toJSON ? c.toJSON() : c)); - const groups = await this.getGroupsWithParticipants(userToken, clubId, t.id); - const matches = await this.getTournamentMatches(userToken, clubId, t.id); - - let allowedAgeKeys = null; - if (rawKeys !== null) { - allowedAgeKeys = new Set(rawKeys); - } - - const tmIds = new Set(); - for (const g of groups || []) { - for (const p of g.participants || []) { - if (p.id && !p.isExternal) tmIds.add(p.id); - } - } - for (const ma of matches || []) { - if (ma.player1Id) tmIds.add(ma.player1Id); - if (ma.player2Id) tmIds.add(ma.player2Id); - } - 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', 'birthDate', 'gender'] }], - }); - for (const row of tms) { - const plain = row.toJSON(); - if (plain.member?.id) { - tmToMemberId.set(plain.id, plain.member.id); - tmToName.set(plain.id, { - firstName: plain.member.firstName || '', - lastName: plain.member.lastName || '', - }); - tmToMemberProfile.set(plain.id, { - birthDate: plain.member.birthDate ?? null, - gender: plain.member.gender ?? null, - }); - } - } - } - - const memberTotals = computeInternalSinglesStatsForTournament({ - groups, - matches, - classes: classesJson, - tmToMemberId, - tmToName, - tmToMemberProfile, - allowedAgeKeys, - tournamentDateIso: t.date, - }); - - for (const [mid, row] of memberTotals) { - const ex = memberAgg.get(mid) || { - memberId: mid, - totalPoints: 0, - tournamentCount: 0, - firstName: '', - lastName: '', - }; - ex.totalPoints += row.points; - ex.tournamentCount += 1; - if (row.firstName) ex.firstName = row.firstName; - if (row.lastName) ex.lastName = row.lastName; - memberAgg.set(mid, ex); - } - } - - const rows = [...memberAgg.values()].map((r) => ({ - memberId: r.memberId, - firstName: r.firstName, - lastName: r.lastName, - totalPoints: r.totalPoints, - tournamentCount: r.tournamentCount, - averagePoints: r.tournamentCount > 0 - ? Math.round((r.totalPoints / r.tournamentCount) * 1000) / 1000 - : 0, - })); - - const absoluteRanking = [...rows].sort((a, b) => - b.totalPoints - a.totalPoints || (a.lastName || '').localeCompare(b.lastName || '', 'de') - ); - const averageRanking = [...rows].sort((a, b) => - b.averagePoints - a.averagePoints || (a.lastName || '').localeCompare(b.lastName || '', 'de') - ); - - const ageClassOptions = getCanonicalTtAgeClassOptions(); - - return { - months: monthsNum, - fromDate: fromStr, - tournamentCount: list.length, - ageClassOptions, - absoluteRanking, - averageRanking, - }; - } - } export default new TournamentService(); diff --git a/frontend/I18N_TODO.md b/frontend/I18N_TODO.md new file mode 100644 index 00000000..c5dcac4d --- /dev/null +++ b/frontend/I18N_TODO.md @@ -0,0 +1,337 @@ +# i18n – Angleichungs-TODO + +Stand der Kennzahlen: **2026-05-15** (Basis `de.json`: **2435** Blatt-Strings, `flatten` + `deepMerge` wie in `src/i18n/index.js`). + +**Quelle der Wahrheit:** `src/i18n/locales/de.json` +**Dateien:** `src/i18n/locales/.json` +**Workflow:** [TRANSLATION_WORKFLOW.md](./TRANSLATION_WORKFLOW.md) + +## Werkzeuge + +| Befehl / Skript | Zweck | +|-----------------|--------| +| `cd frontend && npm run i18n:status` | Batch-/Namespace-Status (Kernbereiche) | +| `cd frontend && npm run i18n:audit -- ` | Fehlende Keys, `= de`, deutsche Marker, Platzhalter | +| `node scripts/check-i18n-completeness.js` | Vollständige Tabelle aller Locales | +| `node scripts/check-i18n-completeness.js --locale fil --top 25` | Top-Namespace + EN-Leak-Beispiele pro Locale | +| `node scripts/generate-mobile-i18n.js` | Nach Web-Locales: Mobile `MobileStrings.kt` aktualisieren | +| `scripts/.venv-i18n/bin/python scripts/fill-i18n-deep.py --locale ` | Übersetzung via deep-translator (mit Cache) | +| `scripts/.venv-i18n/bin/python scripts/apply-i18n-cache.py ` | Cache + EN-Fallback ohne API (bei Rate-Limits) | +| `node scripts/apply-cognate-overrides.js` | Cognates/Produktnamen explizit setzen | +| `node scripts/patch-de-ch-swiss.js` | Nur ß→ss in `de-CH.json` | +| `node scripts/update-i18n-todo-stats.js` | Kennzahlen-Tabellen in dieser Datei aktualisieren | +| `scripts/.venv-i18n/bin/python scripts/fix-en-leaks.py ` | EN-Leaks (≈en-US) in Zielsprache übersetzen | +| `bash scripts/run-fix-en-leaks.sh` | EN-Leak-Fix für `th` `tl` `fil` `fr` `es` `it` `pl` `ja` `zh` | +| `node scripts/patch-en-gb-au.js` | UK-Rechtschreibung in `en-GB` / `en-AU` | +| `node scripts/fill-de-extended-gaps.js` | Fehlende `billing`/`orders`-Keys in `de-extended` | + +### Legende (Kennzahlen pro Locale) + +| Spalte | Bedeutung | +|--------|-----------| +| **explicit** | Blatt-Strings explizit in der Locale-JSON gesetzt | +| **erbtsDE** | Kein Override → im UI noch **deutscher** Text | +| **=de expl** | Override gesetzt, Wert **identisch** zu `de` (bereinigen) | +| **≠de** | Abweichend von Deutsch (Zielsprache oder EN-Vorlage) | +| **≈en-US** | Unter ≠de: Wert wie **en-US-Merge** (EN statt Zielsprache) | + +### Globale Phasen (Reihenfolge) + +- [x] **Phase A – Messbarkeit:** Kennzahlen 2026-05-15 (`check-i18n-completeness.js`, `update-i18n-todo-stats.js`). +- [x] **Phase B – Englisch-Basis:** `en-US` / `en-GB` / `en-AU` (~52 `erbtsDE` Cognates). +- [x] **Phase C – Kern-Namespaces:** per `fill-i18n-deep.py` + Cache für B2–B5. +- [x] **Phase D – Große Rest-Namespaces:** per `apply-i18n-cache.py` (2026-05-15). +- [x] **Phase E – Qualität:** `fix-en-leaks.py` (2026-05-15); Kern ~98 % übersetzt; Rest: Produktnamen/Cognates (`myTischtennis`, IBAN, …). +- [x] **Phase F – Mobile:** `generate-mobile-i18n.js` (15 Locales, 2474 Keys). +- [ ] **Phase G – QA:** Manueller UI-Pass im Browser (siehe unten) – **einziger verbleibender Schritt**. + +### Batch-Vorschlag (Workflow) + +| Batch | Locales | +|-------|---------| +| B1 | `en-US`, `en-GB`, `en-AU` | +| B2 | `fr`, `es` | +| B3 | `it`, `pl` | +| B4 | `ja`, `zh` | +| B5 | `th`, `tl`, `fil` | +| B6 | `de-CH` | +| B7 | `de-extended` (nur Abweichungen von `de`, bewusst) | + +--- + +## `de` – Deutsch (Master) + +**Datei:** `de.json` · **Rolle:** Source of truth · keine Übersetzung nötig. + +| Kennzahl | Wert | +|----------|------| +| Blatt-Strings | 2435 | + +### Aufgaben + +- [ ] Neue Features immer zuerst in `de.json` pflegen. +- [ ] Vor Release: prüfen, dass keine anderen Locales Keys haben, die in `de` fehlen (`check-i18n-completeness.js` / Audit). +- [ ] Nach größeren `de`-Änderungen: abhängige Locales und Mobile-Generator einplanen. + +--- + +## `de-CH` – Deutsch (Schweiz) + +**Datei:** `de-CH.json` · **Label:** Deutsch (Schweiz) + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 1092 | 1343 | 715 | 377 | 2 | + +### Aufgaben + +- [x] **ß→ss:** `patch-de-ch-swiss.js` (40 Patches). +- [x] **377** echte CH-Abweichungen (`≠de`) vorhanden. +- [ ] **Bewusst offen:** viele Strings identisch mit `de` (kein ß) – CH-UI ok, Kennzahl `erbtsDE` irreführend. +- [ ] Optional: weitere CH-Begriffe (z. B. „Training“ → „Training“) manuell nur wo nötig. +- [x] `npm run build` ok. + +--- + +## `de-extended` – Deutsch (erweitert) + +**Datei:** `de-extended.json` · **Label:** Deutsch (erweitert) + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2448 | 0 | 2405 | 30 | 13 | + +### Aufgaben + +- [x] **Bewusst:** `= de` (2405) – Erweiterungs-Locale, kein Vollübersetzungsziel. +- [x] **73** erbte → **0** (`fill-de-extended-gaps.js`, billing/orders). +- [ ] **30** echte Abweichungen (`≠de`) bei Bedarf mit Product validieren. + +--- + +## `en-US` – English (US) + +**Datei:** `en-US.json` · **Label:** English (US) · **Batch:** B1 · **Referenz für EN-Leaks** + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2383 | 52 | 0 | 2383 | 2383 | + +### Aufgaben + +- [x] **1411** erbte deutsche Strings → vollständig ins Englisch (US) übersetzen (`fill-i18n-locales.js`, 2026-05-15). +- [x] Kern-Namespaces (`common`, `navigation`, `club`, `members`, `diary`, `trainingStats`, `courtDrawingTool`). +- [x] Große Rest-Namespaces (`teamManagement`, `tournaments`, `schedule`, …) per Fill-Skript. +- [x] **=de expl** auf 17 bewusste Cognates/Produktnamen reduziert (Filter, TTR, Team, …). +- [x] `npm run i18n:audit -- en-US` (0 missing) + `npm run build`. +- [x] **52** `erbtsDE`: Cognates (IBAN, Team, OK) – im UI korrekt, Kennzahl akzeptiert. +- [x] `en-GB` / `en-AU` aus `en-US` übernommen. + +--- + +## `en-GB` – English (UK) + +**Datei:** `en-GB.json` · **Label:** English (UK) · **Batch:** B1 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2383 | 52 | 0 | 2383 | 2360 | + +### Aufgaben + +- [x] **1164** erbte deutsche Strings → aus `en-US` übernommen (2026-05-15). +- [x] **UK-Anpassungen:** `patch-en-gb-au.js` (colour/centre/…). +- [x] `npm run i18n:audit -- en-GB` (0 missing, 0 german markers in Kern) + `npm run build`. +- [ ] Weitere UK-Terminologie bei Bedarf manuell (Organisation vs Organization). + +--- + +## `en-AU` – English (AU) + +**Datei:** `en-AU.json` · **Label:** English (AU) · **Batch:** B1 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2383 | 52 | 0 | 2383 | 2361 | + +### Aufgaben + +- [x] Wie `en-US`: erbte deutsche Strings aus `en-US`/`en-GB` übernommen (2026-05-15). +- [x] **AU:** wie UK (`patch-en-gb-au.js`). +- [x] `npm run i18n:audit -- en-AU` + `npm run build`. + +--- + +## `es` – Español + +**Datei:** `es.json` · **Label:** Español · **Batch:** B2 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2394 | 41 | 0 | 2394 | 31 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest per `fill-i18n-deep.py` / `apply-i18n-cache.py` (2026-05-15). +- [x] **1445** erbte deutsche Strings → **41** `erbtsDE` (Cognates). +- [x] **EN-Leaks:** 215 → **31** (`fix-en-leaks.py`); Rest: Produktnamen. +- [x] `npm run i18n:audit -- es` (0 missing, 0 german markers). +- [x] Kern-Namespaces **98,1 %** übersetzt. + +--- + +## `fr` – Français + +**Datei:** `fr.json` · **Label:** Français · **Batch:** B2 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2388 | 47 | 0 | 2388 | 72 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (Cache/Deep-Fill, 2026-05-15). +- [x] **1445** erbte deutsche Strings → **47** `erbtsDE` (Cognates). +- [x] **EN-Leaks:** 261 → **72** (`fix-en-leaks.py`). +- [x] `npm run i18n:audit -- fr` (0 missing, 0 german markers). +- [x] Kern **97,9 %** übersetzt. + +--- + +## `it` – Italiano + +**Datei:** `it.json` · **Label:** Italiano · **Batch:** B3 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2392 | 43 | 0 | 2392 | 22 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (2026-05-15). +- [x] **1445** → **43** `erbtsDE` (Cognates). +- [x] **EN-Leaks:** 209 → **22**. +- [x] `npm run i18n:audit -- it` + Kern **98,1 %**. + +--- + +## `pl` – Polski + +**Datei:** `pl.json` · **Label:** Polski · **Batch:** B3 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2384 | 51 | 0 | 2384 | 26 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (2026-05-15). **Hang bei 950/1248:** Cache war vollständig → `apply-i18n-cache.py` statt API. +- [x] **1445** → **51** `erbtsDE` (Cognates). +- [x] **EN-Leaks:** 211 → **26**. +- [x] `npm run i18n:audit -- pl` + Kern **97,5 %**. + +--- + +## `ja` – 日本語 + +**Datei:** `ja.json` · **Label:** 日本語 · **Batch:** B4 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2393 | 42 | 0 | 2393 | 12 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (2026-05-15). +- [x] **1445** → **42** `erbtsDE`. +- [x] **EN-Leaks:** 283 → **12**. +- [x] `npm run i18n:audit -- ja` + Kern **98,1 %**. + +--- + +## `zh` – 中文 + +**Datei:** `zh.json` · **Label:** 中文 · **Batch:** B4 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2393 | 42 | 0 | 2393 | 11 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (2026-05-15). +- [x] **1445** → **42** `erbtsDE`. +- [x] **EN-Leaks:** 283 → **11**. +- [x] Kern **98,1 %** übersetzt. + +--- + +## `th` – ไทย + +**Datei:** `th.json` · **Label:** ไทย · **Batch:** B5 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2384 | 51 | 0 | 2384 | 8 | + +### Aufgaben + +- [x] Kern-Namespaces + Rest (2026-05-15). +- [x] **1445** → **51** `erbtsDE`. +- [x] **EN-Leaks:** 934 → **8** (`fix-en-leaks.py`). +- [x] `npm run i18n:audit -- th` + Kern **98,1 %**. + +--- + +## `tl` – Tagalog + +**Datei:** `tl.json` · **Label:** Tagalog · **Batch:** B5 · (oft als „Bisaya“-Eindruck im UI: viel EN/DE) + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2384 | 51 | 0 | 2384 | 123 | + +### Aufgaben + +- [x] `app.*`, `auth.*`, `calendar.*` + Kern-Namespaces (2026-05-15). +- [x] **1445** → **51** `erbtsDE`. +- [x] **EN-Leaks:** 1755 → **123** (`fix-en-leaks.py`). +- [ ] Optional: `tl`/`fil`-Glossar (gemeinsame Begriffe). +- [x] Kern **98,1 %** übersetzt. + +--- + +## `fil` – Filipino + +**Datei:** `fil.json` · **Label:** Filipino · **Batch:** B5 + +| explicit | erbtsDE | =de expl | ≠de | ≈en-US | +|----------|---------|----------|-----|--------| +| 2384 | 51 | 0 | 2384 | 122 | + +### Aufgaben + +- [x] Sichtbarkeit + Kern-Namespaces (2026-05-15). +- [x] **1445** → **51** `erbtsDE`. +- [x] **EN-Leaks:** 1754 → **122** (`fix-en-leaks.py`). +- [x] Kern **98,1 %** übersetzt. + +--- + +## Nach Abschluss einer Locale + +- [x] Kennzahlen in dieser Datei aktualisieren (2026-05-15). +- [x] `node scripts/generate-mobile-i18n.js`. +- [x] `audit-i18n.js`: prüft jetzt mit `deepMerge(de, locale)` (keine falschen „missing keys“). + +## Phase G – Manueller UI-Check (offen) + +In der App nacheinander mit Sprachumschaltung prüfen: + +- [ ] `/members` – Tabelle, Dialoge, Tooltips +- [ ] `/diary` – Trainingstag, PDF, Gruppen +- [ ] `/training-stats` – Kennzahlen, Filter +- [ ] `th` / `tl` / `fil` – Landing (`home`), Login (`auth`), Kalender +- [ ] `de-CH` – Stichprobe ss/ß (z. B. „Strasse“) + +Details: [TRANSLATION_WORKFLOW.md](./TRANSLATION_WORKFLOW.md) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6cbda7f0..da7ef0ac 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,6 +25,7 @@ "vuex": "^4.1.0" }, "devDependencies": { + "@vitalets/google-translate-api": "^9.2.1", "@vitejs/plugin-vue": "^6.0.2", "eslint": "^9.39.1", "eslint-plugin-vue": "^9.0.0", @@ -1693,6 +1694,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/http-errors": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-1.8.2.tgz", + "integrity": "sha512-EqX+YQxINb+MeXaIqYDASb6U6FCHbWjkj4a1CKDBks3d/QiB2+PqBLyO72vLDgAO1wUI4O+9gweRcQK11bTL/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1720,6 +1728,21 @@ "license": "MIT", "optional": true }, + "node_modules/@vitalets/google-translate-api": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/@vitalets/google-translate-api/-/google-translate-api-9.2.1.tgz", + "integrity": "sha512-zlwQWSjXUZhbZQ6qwtIQ7GdYXFQmJ4wYqzcrYJUxtvzQQwUP+uKUb/SRJaBOQuBntjBjzcdcJoLFrpCKUbIkOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "^1.8.2", + "http-errors": "^2.0.0", + "node-fetch": "^2.6.7" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.3.tgz", @@ -2179,6 +2202,16 @@ "node": ">=0.4.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/detect-libc": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", @@ -2896,6 +2929,27 @@ "node": ">=8.0.0" } }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2940,6 +2994,13 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/iobuffer": { "version": "5.4.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", @@ -3223,6 +3284,27 @@ "node": ">=6.0.0" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-readable-to-web-readable-stream": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", @@ -3611,6 +3693,13 @@ "node": ">=10" } }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -3704,6 +3793,16 @@ "node": ">=0.1.14" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -3811,6 +3910,23 @@ "node": ">=8.0" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4107,6 +4223,24 @@ "vue": "^3.2.0" } }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 04a1a2ad..3ffc805a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,13 @@ "build": "vite build", "serve": "vite preview", "i18n:audit": "node scripts/audit-i18n.js", - "i18n:status": "node scripts/translation-status.js" + "i18n:status": "node scripts/translation-status.js", + "i18n:fill": "node ../scripts/fill-i18n-locales.js --all --delay 500", + "i18n:fill:deep": "PYTHONUNBUFFERED=1 ../scripts/.venv-i18n/bin/python -u ../scripts/fill-i18n-deep.py --all --delay 0.2", + "i18n:fix-leaks": "PYTHONUNBUFFERED=1 ../scripts/.venv-i18n/bin/python -u ../scripts/fix-en-leaks.py", + "i18n:fix-leaks:all": "bash ../scripts/run-fix-en-leaks.sh", + "i18n:completeness": "node ../scripts/check-i18n-completeness.js", + "i18n:update-todo": "node ../scripts/update-i18n-todo-stats.js" }, "dependencies": { "axios": "^1.7.3", @@ -27,6 +33,7 @@ "vuex": "^4.1.0" }, "devDependencies": { + "@vitalets/google-translate-api": "^9.2.1", "@vitejs/plugin-vue": "^6.0.2", "eslint": "^9.39.1", "eslint-plugin-vue": "^9.0.0", diff --git a/frontend/scripts/audit-i18n.js b/frontend/scripts/audit-i18n.js index 263e9dee..d532055c 100644 --- a/frontend/scripts/audit-i18n.js +++ b/frontend/scripts/audit-i18n.js @@ -72,6 +72,28 @@ function loadJson(filePath) { return JSON.parse(fs.readFileSync(filePath, 'utf8')); } +function deepMerge(base, override) { + if (!base || typeof base !== 'object' || Array.isArray(base)) { + return override ?? base; + } + const result = { ...base }; + for (const [key, value] of Object.entries(override || {})) { + if ( + value && + typeof value === 'object' && + !Array.isArray(value) && + result[key] && + typeof result[key] === 'object' && + !Array.isArray(result[key]) + ) { + result[key] = deepMerge(result[key], value); + } else { + result[key] = value; + } + } + return result; +} + function getLocaleCode(localeFile) { return localeFile.replace(/\.json$/, ''); } @@ -84,8 +106,10 @@ function extractPlaceholders(value) { } function auditLocale(localeFile, baseFlat) { + const de = loadJson(path.join(LOCALES_DIR, BASE_LOCALE)); const data = loadJson(path.join(LOCALES_DIR, localeFile)); - const flat = flatten(pickNamespaces(data)); + const merged = deepMerge(JSON.parse(JSON.stringify(de)), data); + const flat = flatten(pickNamespaces(merged)); const localeCode = getLocaleCode(localeFile); const missing = Object.keys(baseFlat).filter(key => !(key in flat)); const sameAsDe = Object.keys(baseFlat).filter(key => flat[key] === baseFlat[key]); diff --git a/frontend/src/components/tournament/TournamentConfigTab.vue b/frontend/src/components/tournament/TournamentConfigTab.vue index 302ee3e3..c37ec633 100644 --- a/frontend/src/components/tournament/TournamentConfigTab.vue +++ b/frontend/src/components/tournament/TournamentConfigTab.vue @@ -1,545 +1,192 @@ @@ -573,30 +220,10 @@ export default { type: Number, required: true }, - numberOfTables: { - type: [Number, null], - default: null - }, isGroupTournament: { type: Boolean, required: true }, - groups: { - type: Array, - default: () => [] - }, - groupMatches: { - type: Array, - default: () => [] - }, - plannedGroupCount: { - type: [Number, null], - default: null - }, - isMiniChampionship: { - type: Boolean, - default: false - }, tournamentClasses: { type: Array, required: true @@ -640,10 +267,6 @@ export default { newClassMinBirthYear: { type: [Number, null], default: null - }, - tournamentWideIsDoubles: { - type: Boolean, - default: false } }, data() { @@ -655,410 +278,17 @@ export default { stage2GroupCount: 2, pools12: [], poolsFinal: [], - stage2StageId: null, - finalStageId: null, finalStageType: 'knockout', finalStageThirdPlace: false, finalStageGroupCount: 1, error: null, success: null, }, - highlightedRuleElement: null, - highlightedRuleTimer: null, }; }, - watch: { - 'stageConfig.finalStageType': { - immediate: true, - handler(newValue) { - if (newValue === 'knockout' && this.stageConfig.poolsFinal.length === 0) { - this.stageConfig.poolsFinal = [{ - fromPlacesText: '1,2', - targetType: 'knockout', - targetGroupCount: 1, - }]; - } - } - }, - 'stageConfig.useIntermediateStage'() { - if (this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0) { - this.stageConfig.poolsFinal = [{ - fromPlacesText: '1,2', - targetType: 'knockout', - targetGroupCount: 1, - }]; - } - } - }, computed: { finalPools() { return this.stageConfig.poolsFinal; - }, - canAdvanceToIntermediate() { - return this.stageConfig.useIntermediateStage && this.stageConfig.pools12.length > 0; - }, - canAdvanceToFinalFromPreliminary() { - return !this.stageConfig.useIntermediateStage && this.finalPools.length > 0; - }, - canAdvanceToFinalFromIntermediate() { - return this.stageConfig.useIntermediateStage && this.finalPools.length > 0; - }, - hasAnyAdvanceRule() { - return this.stageConfig.pools12.length > 0 || this.finalPools.length > 0; - }, - stageValidationIssues() { - const issues = []; - const stage2Label = this.$t('tournaments.intermediateRound'); - const finalLabel = this.$t('tournaments.finalRound'); - const pushIssues = (items) => { - items.forEach((item, index) => { - issues.push({ - ...item, - key: item.key || `${item.transition}-${index}-${item.code}` - }); - }); - }; - - if ( - this.stageConfig.useIntermediateStage && - this.stageConfig.stage2Type === 'groups' && - (!Number.isFinite(Number(this.stageConfig.stage2GroupCount)) || Number(this.stageConfig.stage2GroupCount) < 1) - ) { - issues.push({ - key: 'stage2-group-count', - level: 'error', - title: stage2Label, - message: this.$t('tournaments.stageValidationGroupCountRequired'), - fields: ['stage2-group-count'] - }); - } - - if ( - this.stageConfig.finalStageType === 'groups' && - (!Number.isFinite(Number(this.stageConfig.finalStageGroupCount)) || Number(this.stageConfig.finalStageGroupCount) < 1) - ) { - issues.push({ - key: 'final-group-count', - level: 'error', - title: finalLabel, - message: this.$t('tournaments.stageValidationGroupCountRequired'), - fields: ['final-group-count'] - }); - } - - if (this.stageConfig.useIntermediateStage) { - pushIssues(this.validatePoolRules(this.stageConfig.pools12, { - stageLabel: stage2Label, - transition: 'stage2', - forceKnockout: false, - allowGroupsTarget: true, - expectedTargetType: this.stageConfig.stage2Type, - expectedGroupCount: this.stageConfig.stage2Type === 'groups' - ? Number(this.stageConfig.stage2GroupCount || 0) - : null - })); - } - - pushIssues(this.validatePoolRules(this.finalPools, { - stageLabel: finalLabel, - transition: 'final', - forceKnockout: this.stageConfig.finalStageType === 'knockout', - allowGroupsTarget: this.stageConfig.finalStageType === 'groups', - expectedTargetType: this.stageConfig.finalStageType, - expectedGroupCount: this.stageConfig.finalStageType === 'groups' - ? Number(this.stageConfig.finalStageGroupCount || 0) - : null - })); - - return issues; - }, - stageFlowHealth() { - const errors = this.stageValidationIssues.filter(issue => issue.level === 'error'); - const warnings = this.stageValidationIssues.filter(issue => issue.level === 'warning'); - const hasIntermediateRules = !this.stageConfig.useIntermediateStage || this.stageConfig.pools12.length > 0; - const hasFinalRules = this.finalPools.length > 0; - const hasRulesForAllTransitions = hasIntermediateRules && hasFinalRules; - - if (errors.length > 0) { - return { - level: 'error', - label: this.$t('tournaments.stageFlowHealthBlocked'), - message: this.$t('tournaments.stageFlowHealthBlockedDescription') - }; - } - - if (!hasRulesForAllTransitions || warnings.length > 0) { - return { - level: 'warning', - label: this.$t('tournaments.stageFlowHealthIncomplete'), - message: !hasRulesForAllTransitions - ? this.$t('tournaments.stageFlowHealthMissingRules') - : this.$t('tournaments.stageFlowHealthReviewDescription') - }; - } - - return { - level: 'ok', - label: this.$t('tournaments.stageFlowHealthOk'), - message: this.$t('tournaments.stageFlowHealthOkDescription') - }; - }, - stageFlowBreakdown() { - const items = []; - const buildItem = ({ key, label, transition, enabled, missingRuleMessage }) => { - if (!enabled) return null; - const transitionIssues = this.stageValidationIssues.filter(issue => issue.transition === transition); - const errorCount = transitionIssues.filter(issue => issue.level === 'error').length; - const warningCount = transitionIssues.filter(issue => issue.level === 'warning').length; - const firstIssue = transitionIssues[0] || null; - const hasRules = transition === 'stage2' ? this.stageConfig.pools12.length > 0 : this.finalPools.length > 0; - const readiness = this.getTransitionReadiness(transition); - const focusField = firstIssue?.fields?.[0] - || (transition === 'stage2' ? 'stage2:0:places' : 'final:0:places'); - - if (!hasRules) { - return { - key, - label, - level: 'warning', - message: missingRuleMessage, - focusField, - action: 'add-rule', - actionLabel: this.$t('tournaments.stageFlowBreakdownActionAddRule') - }; - } - if (!readiness.ready) { - return { - key, - label, - level: 'warning', - message: readiness.message, - focusField, - action: 'review', - actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview') - }; - } - if (errorCount > 0) { - return { - key, - label, - level: 'error', - message: this.$t('tournaments.stageFlowBreakdownErrors', { count: errorCount }), - focusField, - action: 'review', - actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview') - }; - } - if (warningCount > 0) { - return { - key, - label, - level: 'warning', - message: this.$t('tournaments.stageFlowBreakdownWarnings', { count: warningCount }), - focusField, - action: 'review', - actionLabel: this.$t('tournaments.stageFlowBreakdownActionReview') - }; - } - return { - key, - label, - level: 'ok', - message: this.$t('tournaments.stageFlowBreakdownOk'), - focusField, - action: 'open', - actionLabel: this.$t('tournaments.stageFlowBreakdownActionOpen') - }; - }; - - if (this.stageConfig.useIntermediateStage) { - items.push(buildItem({ - key: 'stage2', - label: this.$t('tournaments.promotionRule12'), - transition: 'stage2', - enabled: true, - missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule') - })); - items.push(buildItem({ - key: 'final', - label: this.$t('tournaments.promotionRuleFinal', { from: 2, to: 3 }), - transition: 'final', - enabled: true, - missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule') - })); - } else { - items.push(buildItem({ - key: 'final', - label: this.$t('tournaments.promotionRuleFinal', { from: 1, to: 3 }), - transition: 'final', - enabled: true, - missingRuleMessage: this.$t('tournaments.stageFlowBreakdownMissingRule') - })); - } - - return items.filter(Boolean); - }, - autoFixableStageIssues() { - const fixes = []; - const collectRuleFixes = (transition, rules) => { - rules.forEach((rule, index) => { - const placesSuggestion = this.getRulePlacesSuggestion(transition, index); - if (placesSuggestion) { - fixes.push({ type: 'places', transition, index }); - } - ['targetType', 'targetGroupCount'].forEach(field => { - if (this.hasRuleQuickFix(transition, index, field)) { - fixes.push({ type: field, transition, index }); - } - }); - }); - }; - collectRuleFixes('stage2', this.stageConfig.pools12); - collectRuleFixes('final', this.finalPools); - return fixes; - }, - stageFlowRecommendation() { - const missingRuleItem = this.stageFlowBreakdown.find(item => item.action === 'add-rule'); - const firstError = this.stageValidationIssues.find(issue => issue.level === 'error'); - const firstWarning = this.stageValidationIssues.find(issue => issue.level === 'warning'); - - if (this.autoFixableStageIssues.length > 0) { - return { - type: 'apply-fixes', - label: this.$t('tournaments.stageValidationApplyAllFixes', { count: this.autoFixableStageIssues.length }), - message: this.$t('tournaments.stageFlowRecommendationFixes') - }; - } - - if (missingRuleItem) { - return { - type: 'add-rule', - key: missingRuleItem.key, - label: this.$t('tournaments.stageFlowBreakdownActionAddRule'), - message: this.$t('tournaments.stageFlowRecommendationMissingRule', { label: missingRuleItem.label }) - }; - } - - if (firstError) { - return { - type: 'review', - field: firstError.fields?.[0] || null, - label: this.$t('tournaments.stageFlowBreakdownActionReview'), - message: this.$t('tournaments.stageFlowRecommendationError') - }; - } - - if (firstWarning) { - return { - type: 'review', - field: firstWarning.fields?.[0] || null, - label: this.$t('tournaments.stageFlowBreakdownActionReview'), - message: this.$t('tournaments.stageFlowRecommendationWarnings') - }; - } - - return { - type: 'save', - label: this.$t('tournaments.saveRounds'), - message: this.$t('tournaments.stageFlowRecommendationSave') - }; - }, - stageFlowPreviewItems() { - const buildPreview = (transition, label, rules, targetType, targetGroupCount) => { - if (!Array.isArray(rules) || rules.length === 0) { - return { - key: transition, - label, - message: this.$t('tournaments.stageFlowPreviewMissing') - }; - } - const fragments = rules.map(rule => { - const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique.sort((a, b) => a - b).join(','); - const qualifiedCount = this.getRuleQualifiedCount(transition, rule); - const targetLabel = targetType === 'knockout' - ? this.$t('tournaments.knockoutLabel') - : this.$t('tournaments.targetGroupsLabel', { count: targetGroupCount || rule?.targetGroupCount || 1 }); - const base = this.$t('tournaments.stageFlowPreviewRule', { - places: places || '–', - target: targetLabel - }); - if (qualifiedCount == null) { - return base; - } - return this.$t('tournaments.stageFlowPreviewRuleWithCount', { - base, - count: qualifiedCount - }); - }); - return { - key: transition, - label, - message: fragments.join(' · ') - }; - }; - - const items = []; - if (this.stageConfig.useIntermediateStage) { - items.push(buildPreview( - 'stage2', - this.$t('tournaments.promotionRule12'), - this.stageConfig.pools12, - this.stageConfig.stage2Type, - this.stageConfig.stage2GroupCount - )); - items.push(buildPreview( - 'final', - this.$t('tournaments.promotionRuleFinal', { from: 2, to: 3 }), - this.finalPools, - this.stageConfig.finalStageType, - this.stageConfig.finalStageGroupCount - )); - } else { - items.push(buildPreview( - 'final', - this.$t('tournaments.promotionRuleFinal', { from: 1, to: 3 }), - this.finalPools, - this.stageConfig.finalStageType, - this.stageConfig.finalStageGroupCount - )); - } - return items; - }, - hasBlockingStageValidationIssues() { - return this.stageValidationIssues.some(issue => issue.level === 'error'); - }, - stageValidationFieldMap() { - const map = {}; - this.stageValidationIssues.forEach(issue => { - if (!Array.isArray(issue.fields)) return; - issue.fields.forEach(field => { - map[field] ||= []; - map[field].push(issue); - }); - }); - return map; - }, - stageFlowSummary() { - if (this.stageConfig.useIntermediateStage) { - return this.$t('tournaments.stageFlowWithIntermediate', { - stage2: this.stageTypeLabel(this.stageConfig.stage2Type), - final: this.stageTypeLabel(this.stageConfig.finalStageType) - }); - } - return this.$t('tournaments.stageFlowDirectFinal', { - final: this.stageTypeLabel(this.stageConfig.finalStageType) - }); - }, - currentPreliminaryGroupCount() { - const currentGroups = (this.groups || []).filter(group => group && (group.stageId == null || group.stageId === 1)); - if (currentGroups.length > 0) { - return currentGroups.length; - } - const fallback = Number(this.plannedGroupCount); - return Number.isInteger(fallback) && fallback > 0 ? fallback : null; - }, - effectiveGroups() { - return Array.isArray(this.groups) ? this.groups : []; } }, watch: { @@ -1073,7 +303,6 @@ export default { 'update:tournamentName', 'update:tournamentDate', 'update:winningSets', - 'update:numberOfTables', 'update:isGroupTournament', 'generate-pdf', 'edit-class', @@ -1090,548 +319,11 @@ export default { 'update:newClassName', 'update:newClassIsDoubles', 'update:newClassGender', - 'update:newClassMinBirthYear', - 'set-all-classes-doubles' + 'update:newClassMinBirthYear' ] , methods: { - stageTypeLabel(type) { - return type === 'knockout' ? this.$t('tournaments.knockoutLabel') : this.$t('tournaments.groupsLabel'); - }, - poolRulePreview(rule, stage) { - const places = String(rule?.fromPlacesText || '') - .split(',') - .map(value => value.trim()) - .filter(Boolean) - .join(', '); - const targetType = stage === 'final' && this.stageConfig.finalStageType === 'knockout' - ? 'knockout' - : (rule?.targetType || 'groups'); - const targetLabel = targetType === 'knockout' - ? this.$t('tournaments.poolRuleTargetKnockout') - : this.$t('tournaments.poolRuleTargetGroupsDetailed', { - count: rule?.targetGroupCount || (stage === 'final' ? this.stageConfig.finalStageGroupCount || 1 : this.stageConfig.stage2GroupCount || 2) - }); - return this.$t('tournaments.poolRulePreview', { - places: places || '–', - target: targetLabel - }); - }, - applyPresetRule(rule, value) { - if (!rule) return; - rule.fromPlacesText = value; - }, - normalizeRulePlaces(text) { - const rawValues = String(text || '') - .split(',') - .map(value => value.trim()) - .filter(Boolean); - const parsed = rawValues.map(value => Number(value)); - return { - rawValues, - parsed, - valid: parsed.every(value => Number.isInteger(value) && value > 0), - unique: [...new Set(parsed.filter(value => Number.isInteger(value) && value > 0))] - }; - }, - isPowerOfTwo(value) { - const n = Number(value); - return Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0; - }, - getSourceGroupCount(transition) { - if (transition === 'stage2') { - return this.currentPreliminaryGroupCount; - } - if (transition === 'final' && this.stageConfig.useIntermediateStage && this.stageConfig.stage2Type === 'groups') { - const count = Number(this.stageConfig.stage2GroupCount); - return Number.isInteger(count) && count > 0 ? count : null; - } - if (transition === 'final' && !this.stageConfig.useIntermediateStage) { - return this.currentPreliminaryGroupCount; - } - return null; - }, - getSourceGroupsForTransition(transition) { - if (transition === 'stage2') { - return this.effectiveGroups.filter(group => group && (group.stageId == null || group.stageId === undefined)); - } - if (transition === 'final' && this.stageConfig.useIntermediateStage && this.stageConfig.stage2Type === 'groups') { - return this.effectiveGroups.filter(group => group && Number(group.stageId) === Number(this.stageConfig.stage2StageId)); - } - if (transition === 'final' && !this.stageConfig.useIntermediateStage) { - return this.effectiveGroups.filter(group => group && (group.stageId == null || group.stageId === undefined)); - } - return []; - }, - getSourceGroupMatchesForTransition(transition) { - const sourceGroupIds = new Set(this.getSourceGroupsForTransition(transition).map(group => Number(group.groupId ?? group.id))); - return (Array.isArray(this.groupMatches) ? this.groupMatches : []).filter(match => sourceGroupIds.has(Number(match.groupId))); - }, - isGroupReadyForAdvancement(group, transition = 'stage2') { - const participants = Array.isArray(group?.participants) ? group.participants : []; - if (participants.length < 2) { - return true; - } - const groupId = Number(group?.groupId ?? group?.id); - const matches = this.getSourceGroupMatchesForTransition(transition) - .filter(match => Number(match.groupId) === groupId); - const positions = participants - .map(participant => Number(participant.position || 0)) - .filter(position => Number.isInteger(position) && position > 0); - return matches.length > 0 - && matches.every(match => match.isFinished) - && positions.length === participants.length - && new Set(positions).size === positions.length; - }, - getTransitionReadiness(transition) { - const sourceGroups = this.getSourceGroupsForTransition(transition); - if (sourceGroups.length === 0) { - return { - ready: false, - message: this.$t('tournaments.stageFlowReadinessNoGroups') - }; - } - const incompleteGroups = sourceGroups.filter(group => !this.isGroupReadyForAdvancement(group, transition)); - if (incompleteGroups.length > 0) { - return { - ready: false, - message: this.$t('tournaments.stageFlowReadinessIncompleteGroups', { count: incompleteGroups.length }) - }; - } - return { - ready: true, - message: this.$t('tournaments.stageFlowReadinessReady') - }; - }, - getRuleQualifiedCount(transition, rule) { - const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique; - const sourceGroups = this.getSourceGroupsForTransition(transition); - const allReady = sourceGroups.length > 0 && sourceGroups.every(group => this.isGroupReadyForAdvancement(group, transition)); - if (allReady && places.length > 0) { - return sourceGroups.reduce((count, group) => { - const participants = Array.isArray(group?.participants) ? group.participants : []; - return count + participants.filter(participant => places.includes(Number(participant.position || 0))).length; - }, 0); - } - const sourceGroupCount = this.getSourceGroupCount(transition); - if (!Number.isInteger(sourceGroupCount) || sourceGroupCount < 1 || places.length < 1) { - return null; - } - return sourceGroupCount * places.length; - }, - getRuleQualifiedPreview(transition, rule) { - const places = this.normalizeRulePlaces(rule?.fromPlacesText).unique; - if (places.length === 0) return []; - const sourceGroups = this.getSourceGroupsForTransition(transition); - const allReady = sourceGroups.length > 0 && sourceGroups.every(group => this.isGroupReadyForAdvancement(group, transition)); - if (!allReady) return []; - - return sourceGroups.flatMap((group, groupIndex) => { - const participants = Array.isArray(group?.participants) ? group.participants : []; - return participants - .filter(participant => places.includes(Number(participant.position || 0))) - .map(participant => ({ - key: `${group.groupId ?? group.id}-${participant.id}-${participant.position}`, - label: this.$t('tournaments.stageFlowQualifiedPreviewEntry', { - group: group.groupNumber || groupIndex + 1, - position: participant.position, - name: participant.name - }) - })); - }); - }, - validatePoolRules(rules, options = {}) { - const stageLabel = options.stageLabel || this.$t('tournaments.finalRound'); - const transition = options.transition || 'stage'; - const forceKnockout = options.forceKnockout === true; - const allowGroupsTarget = options.allowGroupsTarget !== false; - const expectedTargetType = options.expectedTargetType || null; - const expectedGroupCount = Number(options.expectedGroupCount || 0); - const sourceGroupCount = this.getSourceGroupCount(transition); - const issues = []; - const placeMap = new Map(); - - if (!Array.isArray(rules) || rules.length === 0) { - issues.push({ - code: 'no-rules', - transition, - level: 'warning', - title: stageLabel, - message: this.$t('tournaments.stageValidationNoRules', { stage: stageLabel }), - fields: [] - }); - return issues; - } - - rules.forEach((rule, index) => { - const ruleLabel = this.$t('tournaments.poolRuleLabel', { number: index + 1 }); - const normalized = this.normalizeRulePlaces(rule?.fromPlacesText); - const targetType = forceKnockout ? 'knockout' : (rule?.targetType || 'groups'); - - if (normalized.rawValues.length === 0) { - issues.push({ - code: 'missing-places', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationRulePlacesRequired'), - fields: [`${transition}:${index}:places`] - }); - return; - } - - if (!normalized.valid) { - issues.push({ - code: 'invalid-places', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationRulePlacesInvalid'), - fields: [`${transition}:${index}:places`] - }); - return; - } - - if (normalized.unique.length !== normalized.parsed.length) { - issues.push({ - code: 'duplicate-places', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationRulePlacesDuplicate'), - fields: [`${transition}:${index}:places`] - }); - } - - if ( - normalized.unique.length > 1 && - normalized.unique.some((place, placeIndex) => placeIndex > 0 && place !== normalized.unique[placeIndex - 1] + 1) - ) { - issues.push({ - code: 'non-contiguous-places', - transition, - level: 'warning', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationRulePlacesGap'), - fields: [`${transition}:${index}:places`] - }); - } - - normalized.unique.forEach(place => { - const existingRule = placeMap.get(place); - if (existingRule != null) { - issues.push({ - code: `overlap-${place}`, - transition, - level: 'error', - title: stageLabel, - message: this.$t('tournaments.stageValidationRulesOverlap', { - place, - first: existingRule, - second: index + 1 - }), - fields: [`${transition}:${existingRule - 1}:places`, `${transition}:${index}:places`] - }); - } else { - placeMap.set(place, index + 1); - } - }); - - if (!forceKnockout && targetType === 'groups') { - const groupCount = Number(rule?.targetGroupCount); - if (!Number.isInteger(groupCount) || groupCount < 1) { - issues.push({ - code: 'target-groups-required', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationTargetGroupsRequired'), - fields: [`${transition}:${index}:targetGroupCount`] - }); - } else if (expectedTargetType === 'groups' && Number.isInteger(expectedGroupCount) && expectedGroupCount > 0 && groupCount !== expectedGroupCount) { - issues.push({ - code: 'target-groups-mismatch', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationTargetGroupCountMismatch', { - expected: expectedGroupCount - }), - fields: [`${transition}:${index}:targetGroupCount`] - }); - } - } - - if (!allowGroupsTarget && targetType === 'groups') { - issues.push({ - code: 'target-mismatch', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationKnockoutTargetOnly'), - fields: [`${transition}:${index}:targetType`] - }); - } else if (expectedTargetType && targetType !== expectedTargetType) { - issues.push({ - code: 'target-type-mismatch', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationTargetTypeMismatch', { - target: expectedTargetType === 'knockout' - ? this.$t('tournaments.knockoutLabel') - : this.$t('tournaments.groupsLabel') - }), - fields: [`${transition}:${index}:targetType`] - }); - } - - const qualifiedCount = this.getRuleQualifiedCount(transition, rule); - if (qualifiedCount != null && targetType === 'knockout') { - if (qualifiedCount < 2) { - issues.push({ - code: 'knockout-too-small', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationKnockoutNeedsTwoPlayers'), - fields: [`${transition}:${index}:places`, `${transition}:${index}:targetType`] - }); - } else if (!this.isPowerOfTwo(qualifiedCount)) { - issues.push({ - code: 'knockout-byes-likely', - transition, - level: 'warning', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationKnockoutByesLikely', { count: qualifiedCount }), - fields: [`${transition}:${index}:places`] - }); - } - } - - if (qualifiedCount != null && targetType === 'groups' && Number.isInteger(expectedGroupCount) && expectedGroupCount > 0) { - if (qualifiedCount < expectedGroupCount) { - issues.push({ - code: 'groups-too-few-qualifiers', - transition, - level: 'error', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationNotEnoughQualifiersForGroups', { count: qualifiedCount, groups: expectedGroupCount }), - fields: [`${transition}:${index}:places`, `${transition}:${index}:targetGroupCount`] - }); - } else if (qualifiedCount < expectedGroupCount * 2) { - issues.push({ - code: 'groups-thin', - transition, - level: 'warning', - title: `${stageLabel}: ${ruleLabel}`, - message: this.$t('tournaments.stageValidationThinGroupsLikely', { count: qualifiedCount, groups: expectedGroupCount }), - fields: [`${transition}:${index}:places`, `${transition}:${index}:targetGroupCount`] - }); - } - } - }); - - return issues; - }, - hasStageFieldError(fieldKey) { - return (this.stageValidationFieldMap[fieldKey] || []).some(issue => issue.level === 'error'); - }, - hasRuleFieldError(transition, index, field) { - return (this.stageValidationFieldMap[`${transition}:${index}:${field}`] || []).some(issue => issue.level === 'error'); - }, - getStageFieldHint(fieldKey) { - return (this.stageValidationFieldMap[fieldKey] || [])[0]?.message || ''; - }, - getRuleFieldHint(transition, index, field) { - return (this.stageValidationFieldMap[`${transition}:${index}:${field}`] || [])[0]?.message || ''; - }, - getNormalizedPlacesSuggestion(text) { - const normalized = this.normalizeRulePlaces(text); - if (normalized.unique.length === 0) return ''; - const suggestion = normalized.unique.sort((a, b) => a - b).join(','); - const raw = String(text || '').replace(/\s+/g, ''); - if (!suggestion || suggestion === raw) return ''; - return suggestion; - }, - getRulePlacesSuggestion(transition, index) { - if (!this.hasRuleFieldError(transition, index, 'places')) return ''; - const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12; - const rule = rules[index]; - return this.getNormalizedPlacesSuggestion(rule?.fromPlacesText); - }, - getExpectedTargetType(transition) { - return transition === 'final' - ? this.stageConfig.finalStageType - : this.stageConfig.stage2Type; - }, - getExpectedTargetGroupCount(transition) { - return transition === 'final' - ? Number(this.stageConfig.finalStageGroupCount || 0) - : Number(this.stageConfig.stage2GroupCount || 0); - }, - getRuleFieldIssues(transition, index, field) { - return this.stageValidationFieldMap[`${transition}:${index}:${field}`] || []; - }, - getRuleIssues(transition, index) { - return this.stageValidationIssues.filter(issue => - Array.isArray(issue.fields) && - issue.fields.some(field => field.startsWith(`${transition}:${index}:`)) - ); - }, - getRuleStatus(transition, index) { - const issues = this.getRuleIssues(transition, index); - if (issues.some(issue => issue.level === 'error')) { - return { level: 'error', issue: issues.find(issue => issue.level === 'error') }; - } - if (issues.some(issue => issue.level === 'warning')) { - return { level: 'warning', issue: issues.find(issue => issue.level === 'warning') }; - } - return { level: 'ok', issue: null }; - }, - getRuleStatusLabel(transition, index) { - const status = this.getRuleStatus(transition, index); - if (status.level === 'error') return this.$t('tournaments.ruleStatusBlocked'); - if (status.level === 'warning') return this.$t('tournaments.ruleStatusReview'); - return this.$t('tournaments.ruleStatusOk'); - }, - getRuleStatusMessage(transition, index) { - const status = this.getRuleStatus(transition, index); - if (status.issue?.message) return status.issue.message; - return this.$t('tournaments.ruleStatusOkDescription'); - }, - hasRuleQuickFix(transition, index, field) { - return this.getRuleQuickFixLabel(transition, index, field) !== ''; - }, - getRuleQuickFixLabel(transition, index, field) { - const issues = this.getRuleFieldIssues(transition, index, field); - if (field === 'targetType' && issues.some(issue => issue.code === 'target-type-mismatch' || issue.code === 'target-mismatch')) { - return this.$t('tournaments.stageValidationApplyTargetTypeFix', { - target: this.getExpectedTargetType(transition) === 'knockout' - ? this.$t('tournaments.knockoutLabel') - : this.$t('tournaments.groupsLabel') - }); - } - if (field === 'targetGroupCount' && issues.some(issue => issue.code === 'target-groups-required' || issue.code === 'target-groups-mismatch')) { - return this.$t('tournaments.stageValidationApplyTargetGroupFix', { - count: this.getExpectedTargetGroupCount(transition) - }); - } - return ''; - }, - applyRulePlacesSuggestion(transition, index) { - const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12; - const suggestion = this.getRulePlacesSuggestion(transition, index); - if (!suggestion || !rules[index]) return; - rules[index].fromPlacesText = suggestion; - }, - applyRuleQuickFix(transition, index, field) { - const rules = transition === 'final' ? this.finalPools : this.stageConfig.pools12; - const rule = rules[index]; - if (!rule) return; - if (field === 'targetType') { - rule.targetType = this.getExpectedTargetType(transition); - if (rule.targetType === 'groups' && (!Number.isInteger(Number(rule.targetGroupCount)) || Number(rule.targetGroupCount) < 1)) { - rule.targetGroupCount = Math.max(1, this.getExpectedTargetGroupCount(transition) || 1); - } - return; - } - if (field === 'targetGroupCount') { - rule.targetGroupCount = Math.max(1, this.getExpectedTargetGroupCount(transition) || 1); - } - }, - focusStageField(fieldKey) { - if (!fieldKey) return; - this.$nextTick(() => { - const element = this.$el?.querySelector?.(`[data-field-key="${fieldKey}"]`); - if (!element) return; - const ruleKey = fieldKey.includes(':') - ? fieldKey.split(':').slice(0, 2).join(':') - : null; - this.highlightRule(ruleKey); - element.scrollIntoView({ behavior: 'smooth', block: 'center' }); - if (typeof element.focus === 'function') { - element.focus(); - } - }); - }, - handleStageFlowBreakdownClick(item) { - if (!item) return; - if (item.action === 'add-rule') { - this.addPoolRule(item.key === 'stage2' ? '12' : 'final'); - this.$nextTick(() => { - const rules = item.key === 'stage2' ? this.stageConfig.pools12 : this.finalPools; - const index = Math.max(0, rules.length - 1); - this.focusStageField(`${item.key}:${index}:places`); - }); - return; - } - this.focusStageField(item.focusField); - }, - applyAllStageQuickFixes() { - this.autoFixableStageIssues.forEach(fix => { - if (fix.type === 'places') { - this.applyRulePlacesSuggestion(fix.transition, fix.index); - return; - } - this.applyRuleQuickFix(fix.transition, fix.index, fix.type); - }); - const firstFix = this.autoFixableStageIssues[0]; - if (firstFix) { - const field = firstFix.type === 'places' ? 'places' : firstFix.type; - this.focusStageField(`${firstFix.transition}:${firstFix.index}:${field}`); - } - }, - runStageFlowRecommendation() { - const recommendation = this.stageFlowRecommendation; - if (!recommendation) return; - if (recommendation.type === 'apply-fixes') { - this.applyAllStageQuickFixes(); - return; - } - if (recommendation.type === 'add-rule') { - this.handleStageFlowBreakdownClick({ - key: recommendation.key, - action: 'add-rule' - }); - return; - } - if (recommendation.type === 'review') { - this.focusStageField(recommendation.field); - return; - } - if (recommendation.type === 'save') { - this.onSaveClick(); - } - }, - highlightRule(ruleKey) { - if (!ruleKey) return; - if (this.highlightedRuleTimer) { - clearTimeout(this.highlightedRuleTimer); - this.highlightedRuleTimer = null; - } - if (this.highlightedRuleElement) { - this.highlightedRuleElement.classList.remove('pool-rule--highlighted'); - } - const ruleElement = this.$el?.querySelector?.(`[data-rule-key="${ruleKey}"]`); - if (!ruleElement) return; - ruleElement.classList.add('pool-rule--highlighted'); - this.highlightedRuleElement = ruleElement; - this.highlightedRuleTimer = setTimeout(() => { - if (this.highlightedRuleElement) { - this.highlightedRuleElement.classList.remove('pool-rule--highlighted'); - this.highlightedRuleElement = null; - } - this.highlightedRuleTimer = null; - }, 2200); - }, onSaveClick() { - if (this.hasBlockingStageValidationIssues) { - const firstIssue = this.stageValidationIssues.find(issue => issue.level === 'error'); - this.stageConfig.error = firstIssue?.message || this.$t('tournaments.stageValidationSaveBlocked'); - this.stageConfig.success = null; - return; - } this.saveStageConfig(); }, @@ -1642,7 +334,7 @@ export default { this.stageConfig.success = null; if (!this.clubId || !this.tournamentId) { - this.stageConfig.error = this.$t('tournaments.stageConfigMissingIds'); + this.stageConfig.error = 'Kann nicht speichern: clubId oder tournamentId fehlt.'; return; } @@ -1655,10 +347,10 @@ export default { tournamentId: Number(this.tournamentId), } }); - if (getRes.status >= 400) throw new Error(getRes.data?.error || this.$t('tournaments.stageConfigLoadError')); + if (getRes.status >= 400) throw new Error(getRes.data?.error || 'Fehler beim Laden'); if (!Array.isArray(getRes.data?.stages) || !Array.isArray(getRes.data?.advancements)) { - throw new Error(this.$t('tournaments.stageConfigBadServerResponse')); + throw new Error('Fehlerhafte Antwort vom Server (stages/advancements fehlen).'); } let stages = Array.isArray(getRes.data?.stages) ? getRes.data.stages : []; @@ -1743,7 +435,7 @@ export default { if (putRes.status >= 400) throw new Error(putRes.data?.error || 'Fehler beim Speichern'); await this.loadStageConfig(); - this.stageConfig.success = this.$t('common.saved'); + this.stageConfig.success = 'Gespeichert.'; } catch (e) { this.stageConfig.error = e?.message || String(e); } @@ -1761,7 +453,7 @@ export default { tournamentId: Number(this.tournamentId), } }); - if (res.status >= 400) throw new Error(res.data?.error || this.$t('tournaments.stageConfigLoadError')); + if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Laden der Zwischenrunden'); const stages = Array.isArray(res.data?.stages) ? res.data.stages : []; const advancements = Array.isArray(res.data?.advancements) ? res.data.advancements : []; @@ -1771,21 +463,16 @@ export default { this.stageConfig.useIntermediateStage = !!stage2; if (stage2) { - this.stageConfig.stage2StageId = Number(stage2.id); this.stageConfig.stage2Type = stage2.type || 'groups'; this.stageConfig.stage2GroupCount = stage2.numberOfGroups || 2; - } else { - this.stageConfig.stage2StageId = null; } const stage3 = stages.find(s => Number(s.index) === 3); if (stage3) { - this.stageConfig.finalStageId = Number(stage3.id); this.stageConfig.finalStageType = stage3.type || 'knockout'; this.stageConfig.finalStageGroupCount = stage3.numberOfGroups || 1; } else { // Fallback, wenn bisher nur 1->2 existierte - this.stageConfig.finalStageId = null; this.stageConfig.finalStageType = 'knockout'; this.stageConfig.finalStageGroupCount = 1; } @@ -1812,14 +499,6 @@ export default { targetGroupCount: p?.target?.groupCount || this.stageConfig.finalStageGroupCount || 1, })); - if (this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0) { - this.stageConfig.poolsFinal = [{ - fromPlacesText: '1,2', - targetType: 'knockout', - targetGroupCount: 1, - }]; - } - // KO-Flag gilt für die gesamte Endrunde: true, sobald irgendeine Final-KO-Regel thirdPlace=true hat. this.stageConfig.finalStageThirdPlace = poolsFinal.some(p => p?.target?.type === 'knockout' && p?.target?.thirdPlace === true); } catch (e) { @@ -1868,9 +547,7 @@ export default { ? this.buildPoolsPayload(this.stageConfig.pools12, this.stageConfig.stage2GroupCount || 2, false) : []; const poolsFinal = this.buildPoolsPayload( - this.stageConfig.finalStageType === 'knockout' && this.stageConfig.poolsFinal.length === 0 - ? [{ fromPlacesText: '1,2', targetType: 'knockout', targetGroupCount: 1 }] - : this.stageConfig.poolsFinal, + this.stageConfig.poolsFinal, this.stageConfig.finalStageGroupCount || 1, true, this.stageConfig.finalStageThirdPlace === true @@ -1944,7 +621,7 @@ export default { const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0; if (!hasPools) { const label = `${adv.fromStageIndex}→${adv.toStageIndex}`; - throw new Error(this.$t('tournaments.atLeastOnePoolRule', { label })); + throw new Error(`Bitte mindestens eine Pool-Regel für ${label} anlegen (z.B. Plätze 1,2).`); } } @@ -1954,41 +631,14 @@ export default { stages, advancements, }); - if (res.status >= 400) throw new Error(res.data?.error || this.$t('messages.saveFailed')); + if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Speichern'); await this.loadStageConfig(); - this.stageConfig.success = this.$t('common.saved'); + this.stageConfig.success = 'Gespeichert.'; } catch (e) { this.stageConfig.error = e?.message || String(e); } }, - async ensureStageConfigurationPersisted(existingAdvancements = null) { - const advancements = Array.isArray(existingAdvancements) ? existingAdvancements : null; - if (advancements && advancements.length > 0) return; - - const { stages, advancements: builtAdvancements } = this.buildPayload(); - if (!Array.isArray(builtAdvancements) || builtAdvancements.length === 0) { - throw new Error(this.$t('tournaments.stageConfigLoadError')); - } - - for (const adv of builtAdvancements) { - const hasPools = Array.isArray(adv?.config?.pools) && adv.config.pools.length > 0; - if (!hasPools) { - const label = `${adv.fromStageIndex}→${adv.toStageIndex}`; - throw new Error(this.$t('tournaments.atLeastOnePoolRule', { label })); - } - } - - const saveRes = await apiClient.put('/tournament/stages', { - clubId: Number(this.clubId), - tournamentId: Number(this.tournamentId), - stages, - advancements: builtAdvancements, - }); - if (saveRes.status >= 400) { - throw new Error(saveRes.data?.error || this.$t('messages.saveFailed')); - } - }, async advanceStage(fromStageIndex, toStageIndex) { this.stageConfig.error = null; this.stageConfig.success = null; @@ -2000,28 +650,14 @@ export default { tournamentId: Number(this.tournamentId) } }); - const advancements = Array.isArray(getRes?.data?.advancements) ? getRes.data.advancements : []; - - // Alte Turniere haben teilweise Stages, aber noch keine gespeicherten Advancements. - // Dann ziehen wir die Konfiguration vor dem eigentlichen Start automatisch nach. - await this.ensureStageConfigurationPersisted(advancements); - - const refreshedRes = advancements.length > 0 - ? getRes - : await apiClient.get('/tournament/stages', { - params: { - clubId: Number(this.clubId), - tournamentId: Number(this.tournamentId) - } - }); - const refreshedStages = Array.isArray(refreshedRes?.data?.stages) ? refreshedRes.data.stages : []; - const refreshedNormalized = refreshedStages.map(s => ({ + const stages = Array.isArray(getRes?.data?.stages) ? getRes.data.stages : []; + const normalized = stages.map(s => ({ stageIndex: Number(s.stageIndex ?? s.index ?? s.id), stageId: Number(s.id ?? s.stageId ?? s.stageIndex), type: s.type || s.targetType || s.target })); - const from = refreshedNormalized.find(s => s.stageIndex === Number(fromStageIndex)); - const to = refreshedNormalized.find(s => s.stageIndex === Number(toStageIndex)); + const from = normalized.find(s => s.stageIndex === Number(fromStageIndex)); + const to = normalized.find(s => s.stageIndex === Number(toStageIndex)); const payload = { clubId: Number(this.clubId), @@ -2036,726 +672,13 @@ export default { } const res = await apiClient.post('/tournament/stages/advance', payload); - if (res.status >= 400) throw new Error(res.data?.error || this.$t('tournaments.errorCreatingGroups')); - this.stageConfig.success = this.$t('tournaments.stageParticipantsTransferred', { - round: this.$t(toStageIndex === 2 ? 'tournaments.intermediateRound' : 'tournaments.finalRound') - }); + if (res.status >= 400) throw new Error(res.data?.error || 'Fehler beim Erstellen der Runde'); + this.stageConfig.success = `Runde ${toStageIndex} wurde erstellt.`; } catch (e) { this.stageConfig.error = e?.message || String(e); } } - }, - beforeUnmount() { - if (this.highlightedRuleTimer) { - clearTimeout(this.highlightedRuleTimer); - } } }; - diff --git a/frontend/src/components/tournament/TournamentGroupsTab.vue b/frontend/src/components/tournament/TournamentGroupsTab.vue index 46268f2a..c521b105 100644 --- a/frontend/src/components/tournament/TournamentGroupsTab.vue +++ b/frontend/src/components/tournament/TournamentGroupsTab.vue @@ -8,126 +8,57 @@ @update:modelValue="$emit('update:selectedViewClass', $event)" />
-
-
-

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

-
- {{ groups.length }} {{ $t('tournaments.group') }} - {{ filteredGroupMatches.length }} {{ $t('tournaments.groupMatches') }} -
-
- -
- + + +
+

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

+

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

+
+ - -
- -
-

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

-

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

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

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

-
+
-
-
- +
@@ -234,19 +159,19 @@ export default { required: true }, advancingPerGroup: { - type: [Number, String], + type: Number, required: true }, maxGroupSize: { - type: [Number, String], + type: Number, default: null }, groupsPerClassInput: { - type: [Number, String], + type: Number, default: 0 }, numberOfGroups: { - type: [Number, String], + type: Number, required: true }, groups: { @@ -282,82 +207,18 @@ export default { 'randomize-groups', 'reset-groups', 'reset-matches', - 'cleanup-orphaned-matches', - 'create-matches', - 'highlight-match', - 'go-to-match', - 'merge-pools' + 'create-matches', + 'highlight-match' ], - data() { - return { - // Merge-UI (Pools) - mergeSourceClassId: null, - mergeTargetClassId: null, - mergeStrategy: 'distribute', // 'singleGroup' | 'distribute' - mergeSourceAsAK: false, - }; - }, computed: { filteredGroupMatches() { return this.filterMatchesByClass(this.matches.filter(m => m.round === 'group')); - }, - mergePoolsReady() { - return !!( - this.mergeSourceClassId && - this.mergeTargetClassId && - String(this.mergeSourceClassId) !== String(this.mergeTargetClassId) - ); - }, - mergeSourceClassName() { - const id = this.mergeSourceClassId; - if (!id) return ''; - const c = (this.tournamentClasses || []).find(x => String(x.id) === String(id)); - return c ? c.name : ''; - }, - mergeOutOfCompetitionLabel() { - const base = this.$t && this.$t('tournaments.outOfCompetition'); - // Wenn Übersetzung vorhanden ist, ersetzen wir nur das "A" nicht zuverlässig -> lieber dynamisch bauen - const src = this.mergeSourceClassName || 'Quelle'; - return `Spieler aus ${src} außer Konkurrenz`; } }, methods: { - normalizeNumberInput(rawValue, { min = 0, allowEmpty = false } = {}) { - if (rawValue === '' || rawValue === null || rawValue === undefined) { - return allowEmpty ? '' : min; - } - const parsed = parseInt(String(rawValue), 10); - if (!Number.isFinite(parsed)) { - return allowEmpty ? '' : min; - } - return Math.max(min, parsed); - }, - onGroupsPerClassBlur(rawValue) { - const normalized = this.normalizeNumberInput(rawValue, { min: 0, allowEmpty: false }); - this.$emit('update:groupsPerClassInput', normalized); - this.$emit('group-count-change'); - }, - onNumberOfGroupsBlur(rawValue) { - const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: false }); - this.$emit('update:numberOfGroups', normalized); - this.$emit('group-count-change'); - }, - onAdvancingPerGroupBlur(rawValue) { - const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: false }); - this.$emit('update:advancingPerGroup', normalized); - this.$emit('modus-change'); - }, - onMaxGroupSizeBlur(rawValue) { - const normalized = this.normalizeNumberInput(rawValue, { min: 1, allowEmpty: true }); - this.$emit('update:maxGroupSize', normalized); - }, - groupRankingsForGroup(group) { - const key = `${group.groupId}-${group.classId ?? 'null'}`; - return this.groupRankings[key] || []; - }, filterMatchesByClass(matches) { // Wenn keine Klasse ausgewählt ist (null), zeige alle - if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') { + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { return matches; } // Wenn "Ohne Klasse" ausgewählt ist @@ -379,7 +240,7 @@ export default { }, shouldShowClass(classId) { // Wenn keine Klasse ausgewählt ist (null), zeige alle - if (this.selectedViewClass === null || this.selectedViewClass === undefined || this.selectedViewClass === '' || this.selectedViewClass === '__all__' || this.selectedViewClass === 'all') { + if (this.selectedViewClass === null || this.selectedViewClass === undefined) { return true; } // Wenn "Ohne Klasse" ausgewählt ist @@ -416,14 +277,11 @@ export default { return classItem ? Boolean(classItem.isDoubles) : false; }, getMatchLiveResult(player1Id, player2Id, groupId) { - const id1 = (m) => m.player1?.id ?? m.player1Id; - const id2 = (m) => m.player2?.id ?? m.player2Id; const match = this.matches.find(m => m.round === 'group' && m.groupId === groupId && - id1(m) != null && id2(m) != null && - ((id1(m) === player1Id && id2(m) === player2Id) || - (id1(m) === player2Id && id2(m) === player1Id)) + ((m.player1.id === player1Id && m.player2.id === player2Id) || + (m.player1.id === player2Id && m.player2.id === player1Id)) ); if (!match) return null; @@ -473,9 +331,8 @@ export default { return classes; }, - getLivePosition(playerId, group) { - const groupId = group && typeof group === 'object' ? group.groupId : group; - const groupPlayers = this.groupRankingsForGroup && group && typeof group === 'object' ? this.groupRankingsForGroup(group) : (this.groupRankings[groupId] || []); + getLivePosition(playerId, groupId) { + const groupPlayers = this.groupRankings[groupId] || []; const liveStats = groupPlayers.map(player => { let livePoints = player.points || 0; let liveSetsWon = player.setsWon || 0; @@ -484,13 +341,13 @@ export default { const playerMatches = this.matches.filter(m => m.round === 'group' && m.groupId === groupId && - ((m.player1?.id ?? m.player1Id) === player.id || (m.player2?.id ?? m.player2Id) === player.id) && + (m.player1.id === player.id || m.player2.id === player.id) && !m.isFinished && m.tournamentResults && m.tournamentResults.length > 0 ); playerMatches.forEach(match => { - const isPlayer1 = (match.player1?.id ?? match.player1Id) === player.id; + const isPlayer1 = match.player1.id === player.id; match.tournamentResults.forEach(result => { if (isPlayer1) { if (result.pointsPlayer1 > result.pointsPlayer2) { @@ -526,170 +383,9 @@ export default { }); const position = liveStats.findIndex(p => p.id === playerId) + 1; - return position || (groupPlayers.findIndex(p => p.id === playerId) + 1) || 0; - }, - handleMatchClick(player1Id, player2Id, groupId) { - // Highlight das Match - this.$emit('highlight-match', player1Id, player2Id, groupId); - // Finde das Match und gehe zum Ergebnistab (player1/player2 können null sein, wenn Spieler gelöscht) - const id1 = (m) => m.player1?.id ?? m.player1Id; - const id2 = (m) => m.player2?.id ?? m.player2Id; - const match = this.matches.find(m => - m.round === 'group' && - m.groupId === groupId && - id1(m) != null && id2(m) != null && - ((id1(m) === player1Id && id2(m) === player2Id) || - (id1(m) === player2Id && id2(m) === player1Id)) - ); - if (match) { - this.$emit('go-to-match', match.id); - } - }, - requestMergePools() { - if (!this.mergeSourceClassId || !this.mergeTargetClassId) return; - if (String(this.mergeSourceClassId) === String(this.mergeTargetClassId)) return; - this.$emit('merge-pools', { - sourceClassId: Number(this.mergeSourceClassId), - targetClassId: Number(this.mergeTargetClassId), - strategy: this.mergeStrategy, - outOfCompetitionForSource: !!this.mergeSourceAsAK, - }); + return position || groupPlayers.findIndex(p => p.id === playerId) + 1; } } }; - diff --git a/frontend/src/components/tournament/TournamentPlacementsTab.vue b/frontend/src/components/tournament/TournamentPlacementsTab.vue index f27b9964..d85c9ba7 100644 --- a/frontend/src/components/tournament/TournamentPlacementsTab.vue +++ b/frontend/src/components/tournament/TournamentPlacementsTab.vue @@ -7,43 +7,45 @@ :selected-date="selectedDate" @update:modelValue="$emit('update:selectedViewClass', $event)" /> -
+

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

-
- {{ Object.keys(visibleFinalPlacementsByClass).length }} {{ $t('tournaments.classes') }} - {{ totalVisibleFinalPlacements }} {{ $t('tournaments.tabPlacements') }} -
-