Enhance tournament management with new features and UI improvements

This commit introduces several enhancements to the tournament management system, including the addition of winning sets to tournament creation and updates. The `updateTournament` and `addTournament` methods in the backend now accept winning sets as a parameter, ensuring proper validation and handling. New functionality for updating participant seeded status and setting match activity is also implemented, along with corresponding routes and controller methods. The frontend is updated to reflect these changes, featuring new input fields for winning sets and improved participant management UI, enhancing overall user experience and interactivity.
This commit is contained in:
Torsten Schulz (local)
2025-11-14 14:36:21 +01:00
parent d48cc4385f
commit 3334d76688
14 changed files with 609 additions and 208 deletions

View File

@@ -55,7 +55,6 @@ export const updateParticipantGroup = async (req, res) => {
// Emit Socket-Event mit dem aktualisierten Participant
if (updatedParticipant?.diaryDate?.clubId) {
console.log('📡 [Backend] Emit participant:updated mit groupId:', updatedParticipant.groupId);
emitParticipantUpdated(updatedParticipant.diaryDate.clubId, dateId, updatedParticipant);
}

View File

@@ -18,9 +18,9 @@ export const getTournaments = async (req, res) => {
// 2. Neues Turnier anlegen
export const addTournament = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentName, date } = req.body;
const { clubId, tournamentName, date, winningSets } = req.body;
try {
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date);
const tournament = await tournamentService.addTournament(token, clubId, tournamentName, date, winningSets);
// Emit Socket-Event
if (clubId && tournament && tournament.id) {
emitTournamentChanged(clubId, tournament.id);
@@ -139,9 +139,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 } = req.body;
const { name, date, winningSets } = req.body;
try {
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date);
const tournament = await tournamentService.updateTournament(token, clubId, tournamentId, name, date, winningSets);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json(tournament);
@@ -281,6 +281,21 @@ export const removeParticipant = async (req, res) => {
}
};
export const updateParticipantSeeded = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, participantId } = req.params;
const { seeded } = req.body;
try {
await tournamentService.updateParticipantSeeded(token, clubId, tournamentId, participantId, seeded);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Gesetzt-Status aktualisiert' });
} catch (err) {
console.error('[updateParticipantSeeded] 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;
@@ -330,4 +345,19 @@ export const deleteKnockoutMatches = async (req, res) => {
res.status(500).json({ error: error.message });
}
};
export const setMatchActive = async (req, res) => {
const { authcode: token } = req.headers;
const { clubId, tournamentId, matchId } = req.params;
const { isActive } = req.body;
try {
await tournamentService.setMatchActive(token, clubId, tournamentId, matchId, isActive);
// Emit Socket-Event
emitTournamentChanged(clubId, tournamentId);
res.status(200).json({ message: 'Match-Status aktualisiert' });
} catch (err) {
console.error('[setMatchActive] Error:', err);
res.status(500).json({ error: err.message });
}
};

View File

@@ -0,0 +1,22 @@
-- Migration: Add 'is_active' column to tournament_match table
-- Date: 2025-01-14
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament_match';
SET @columnname = 'is_active';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `is_finished`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -0,0 +1,24 @@
-- Migration: Add seeded column to tournament_member table
-- Date: 2025-01-13
-- For MariaDB/MySQL
-- Add seeded column if it doesn't exist
-- Check if column exists and add it if not
SET @dbname = DATABASE();
SET @tablename = 'tournament_member';
SET @columnname = 'seeded';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` TINYINT(1) NOT NULL DEFAULT 0 AFTER `club_member_id`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -0,0 +1,22 @@
-- Migration: Add 'winning_sets' column to tournament table
-- Date: 2025-01-14
-- For MariaDB/MySQL
SET @dbname = DATABASE();
SET @tablename = 'tournament';
SET @columnname = 'winning_sets';
SET @preparedStatement = (SELECT IF(
(
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE
(TABLE_SCHEMA = @dbname)
AND (TABLE_NAME = @tablename)
AND (COLUMN_NAME = @columnname)
) > 0,
'SELECT 1',
CONCAT('ALTER TABLE `', @tablename, '` ADD COLUMN `', @columnname, '` INT NOT NULL DEFAULT 3 AFTER `advancing_per_group`')
));
PREPARE alterIfNotExists FROM @preparedStatement;
EXECUTE alterIfNotExists;
DEALLOCATE PREPARE alterIfNotExists;

View File

@@ -17,6 +17,7 @@ const Tournament = sequelize.define('Tournament', {
advancingPerGroup: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 1,
},
numberOfGroups: {
type: DataTypes.INTEGER,
@@ -28,7 +29,11 @@ const Tournament = sequelize.define('Tournament', {
allowNull: false,
defaultValue: 1
},
advancingPerGroup: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 1 },
winningSets: {
type: DataTypes.INTEGER,
allowNull: false,
defaultValue: 3,
},
}, {
underscored: true,
tableName: 'tournament',

View File

@@ -46,6 +46,11 @@ const TournamentMatch = sequelize.define('TournamentMatch', {
allowNull: false,
defaultValue: false,
},
isActive: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
result: {
type: DataTypes.STRING,
allowNull: true,

View File

@@ -16,6 +16,11 @@ const TournamentMember = sequelize.define('TournamentMember', {
type: DataTypes.INTEGER,
autoIncrement: false,
allowNull: false
},
seeded: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false
}
}, {
underscored: true,

View File

@@ -18,9 +18,11 @@ import {
resetGroups,
resetMatches,
removeParticipant,
updateParticipantSeeded,
deleteMatchResult,
reopenMatch,
deleteKnockoutMatches,
setMatchActive,
} from '../controllers/tournamentController.js';
import { authenticate } from '../middleware/authMiddleware.js';
@@ -29,6 +31,7 @@ const router = express.Router();
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.post('/modus', authenticate, setModus);
router.post('/groups/reset', authenticate, resetGroups);
router.post('/matches/reset', authenticate, resetMatches);
@@ -39,6 +42,7 @@ router.post('/match/result', authenticate, addMatchResult);
router.delete('/match/result', authenticate, deleteMatchResult);
router.post("/match/reopen", reopenMatch);
router.post('/match/finish', authenticate, finishMatch);
router.put('/match/:clubId/:tournamentId/:matchId/active', authenticate, setMatchActive);
router.get('/matches/:clubId/:tournamentId', authenticate, getTournamentMatches);
router.put('/:clubId/:tournamentId', authenticate, updateTournament);
router.get('/:clubId/:tournamentId', authenticate, getTournament);

View File

@@ -12,24 +12,20 @@ export const initializeSocketIO = (httpServer) => {
});
io.on('connection', (socket) => {
console.log('🔌 Socket verbunden:', socket.id);
// Client tritt einem Club-Raum bei
socket.on('join-club', (clubId) => {
const room = `club-${clubId}`;
socket.join(room);
console.log(`👤 Socket ${socket.id} ist Club ${clubId} beigetreten`);
});
// Client verlässt einen Club-Raum
socket.on('leave-club', (clubId) => {
const room = `club-${clubId}`;
socket.leave(room);
console.log(`👤 Socket ${socket.id} hat Club ${clubId} verlassen`);
});
socket.on('disconnect', () => {
console.log('🔌 Socket getrennt:', socket.id);
// Socket getrennt
});
});
@@ -46,11 +42,10 @@ export const getIO = () => {
// Helper-Funktionen zum Emittieren von Events
export const emitToClub = (clubId, event, data) => {
if (!io) {
console.warn('⚠️ [Socket] emitToClub: io nicht initialisiert');
console.warn('emitToClub: io nicht initialisiert');
return;
}
const room = `club-${clubId}`;
console.log(`📡 [Socket] Emit ${event} an Raum ${room}:`, data);
io.to(room).emit(event, data);
};
@@ -64,7 +59,6 @@ export const emitParticipantRemoved = (clubId, dateId, participantId) => {
};
export const emitParticipantUpdated = (clubId, dateId, participant) => {
console.log('📡 [Socket] Emit participant:updated für Club', clubId, 'Date', dateId, 'Participant:', participant);
emitToClub(clubId, 'participant:updated', { dateId, participant });
};

View File

@@ -41,7 +41,7 @@ class TournamentService {
}
// 2. Neues Turnier anlegen (prüft Duplikat)
async addTournament(userToken, clubId, tournamentName, date) {
async addTournament(userToken, clubId, tournamentName, date, winningSets) {
await checkAccess(userToken, clubId);
const existing = await Tournament.findOne({ where: { clubId, date } });
if (existing) {
@@ -52,7 +52,8 @@ class TournamentService {
date,
clubId: +clubId,
bestOfEndroundSize: 0,
type: ''
type: '',
winningSets: winningSets || 3 // Default: 3 Sätze
});
return JSON.parse(JSON.stringify(t));
}
@@ -183,21 +184,108 @@ class TournamentService {
{ where: { tournamentId } }
);
// 4) Zufällig verteilen
const shuffled = members.slice();
for (let i = shuffled.length - 1; i > 0; i--) {
// 4) Gleichmäßige Verteilung: Zuerst alle Spieler gleichmäßig verteilen,
// dann gesetzte Spieler gleichmäßig umverteilen, um sie gleichmäßig zu verteilen
const seededMembers = members.filter(m => m.seeded);
const unseededMembers = members.filter(m => !m.seeded);
// Alle Spieler zufällig mischen
const allMembers = members.slice();
for (let i = allMembers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
[allMembers[i], allMembers[j]] = [allMembers[j], allMembers[i]];
}
// Warte auf alle Updates, damit die Zuordnungen korrekt sind
// Berechne die gewünschte Anzahl pro Gruppe
const totalMembers = allMembers.length;
const numGroups = groups.length;
const baseSize = Math.floor(totalMembers / numGroups);
const remainder = totalMembers % numGroups;
// Verteile alle Spieler gleichmäßig (round-robin)
const groupAssignments = groups.map(() => []);
allMembers.forEach((m, idx) => {
const targetGroup = idx % numGroups;
groupAssignments[targetGroup].push(m);
});
// Jetzt optimiere die Verteilung: Stelle sicher, dass die Gruppen gleichmäßig groß sind
// und die gesetzten Spieler gleichmäßig verteilt sind
// Zuerst: Stelle sicher, dass die Gruppen gleichmäßig groß sind
// Sortiere Gruppen nach Größe (größte zuerst)
const groupSizes = groupAssignments.map((group, idx) => ({ idx, size: group.length }));
groupSizes.sort((a, b) => b.size - a.size);
// Verschiebe Spieler von größeren zu kleineren Gruppen
for (let i = 0; i < numGroups - 1; i++) {
const largeGroupIdx = groupSizes[i].idx;
const smallGroupIdx = groupSizes[numGroups - 1].idx;
while (groupAssignments[largeGroupIdx].length > groupAssignments[smallGroupIdx].length + 1) {
// Verschiebe einen Spieler von der größeren zur kleineren Gruppe
const memberToMove = groupAssignments[largeGroupIdx].pop();
groupAssignments[smallGroupIdx].push(memberToMove);
}
}
// Jetzt optimiere die Verteilung der gesetzten Spieler:
// Zähle gesetzte Spieler pro Gruppe
const seededCounts = groupAssignments.map(group =>
group.filter(m => m.seeded).length
);
// Verschiebe gesetzte Spieler, um eine gleichmäßigere Verteilung zu erreichen
for (let round = 0; round < 10; round++) { // Maximal 10 Runden für Optimierung
let changed = false;
// Finde Gruppe mit den meisten gesetzten Spielern
let maxSeededIdx = 0;
let maxSeededCount = seededCounts[0];
for (let i = 1; i < numGroups; i++) {
if (seededCounts[i] > maxSeededCount) {
maxSeededCount = seededCounts[i];
maxSeededIdx = i;
}
}
// Finde Gruppe mit den wenigsten gesetzten Spielern
let minSeededIdx = 0;
let minSeededCount = seededCounts[0];
for (let i = 1; i < numGroups; i++) {
if (seededCounts[i] < minSeededCount) {
minSeededCount = seededCounts[i];
minSeededIdx = i;
}
}
// Wenn die Differenz größer als 1 ist, verschiebe einen gesetzten Spieler
if (maxSeededCount - minSeededCount > 1) {
// Finde einen gesetzten Spieler in der Gruppe mit den meisten
const seededInMax = groupAssignments[maxSeededIdx].filter(m => m.seeded);
if (seededInMax.length > 0) {
const memberToMove = seededInMax[0];
// Entferne aus max-Gruppe
groupAssignments[maxSeededIdx] = groupAssignments[maxSeededIdx].filter(m => m.id !== memberToMove.id);
seededCounts[maxSeededIdx]--;
// Füge zu min-Gruppe hinzu
groupAssignments[minSeededIdx].push(memberToMove);
seededCounts[minSeededIdx]++;
changed = true;
}
}
if (!changed) break;
}
// Warte auf alle Updates
const updatePromises = [];
groups.forEach((g, idx) => {
const groupMembers = shuffled.filter((_, i) => i % groups.length === idx);
groupMembers.forEach(m => {
updatePromises.push(m.update({ groupId: g.id }));
groupAssignments.forEach((groupMembers, groupIdx) => {
groupMembers.forEach(member => {
updatePromises.push(member.update({ groupId: groups[groupIdx].id }));
});
});
await Promise.all(updatePromises);
// 5) RoundRobin anlegen wie gehabt - NUR innerhalb jeder Gruppe
@@ -270,7 +358,8 @@ class TournamentService {
groupNumber: idx + 1, // jetzt definiert
participants: g.tournamentGroupMembers.map(m => ({
id: m.id,
name: `${m.member.firstName} ${m.member.lastName}`
name: `${m.member.firstName} ${m.member.lastName}`,
seeded: m.seeded || false
}))
}));
}
@@ -281,11 +370,17 @@ class TournamentService {
await checkAccess(userToken, clubId);
const t = await Tournament.findOne({ where: { id: tournamentId, clubId } });
if (!t) throw new Error('Turnier nicht gefunden');
return t;
// Stelle sicher, dass winningSets vorhanden ist (für alte Turniere)
// Nur setzen, wenn es wirklich null/undefined ist (nicht 0 oder andere Werte)
if (t.winningSets == null) {
t.winningSets = 3;
await t.save();
}
return JSON.parse(JSON.stringify(t));
}
// Update Turnier (Name und Datum)
async updateTournament(userToken, clubId, tournamentId, name, date) {
// 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) {
@@ -302,6 +397,12 @@ class TournamentService {
if (name !== undefined) tournament.name = name;
if (date !== undefined) tournament.date = date;
if (winningSets !== undefined) {
if (winningSets < 1) {
throw new Error('Anzahl der Gewinnsätze muss mindestens 1 sein');
}
tournament.winningSets = winningSets;
}
await tournament.save();
return JSON.parse(JSON.stringify(tournament));
@@ -331,8 +432,16 @@ class TournamentService {
// 12. Satz-Ergebnis hinzufügen/überschreiben
async addMatchResult(userToken, clubId, tournamentId, matchId, set, result) {
await checkAccess(userToken, clubId);
const [match] = await TournamentMatch.findAll({ where: { id: matchId, tournamentId } });
if (!match) throw new Error('Match nicht gefunden');
// Lade Turnier, um winningSets zu erhalten
const tournament = await Tournament.findByPk(tournamentId);
if (!tournament) throw new Error('Turnier nicht gefunden');
const winningSets = tournament.winningSets || 3; // Default: 3 Sätze
// Lade Match, um isFinished zu prüfen
const match = await TournamentMatch.findByPk(matchId);
if (!match || match.tournamentId != tournamentId) throw new Error('Match nicht gefunden');
const existing = await TournamentResult.findOne({ where: { matchId, set } });
if (existing) {
existing.pointsPlayer1 = +result.split(':')[0];
@@ -342,6 +451,32 @@ class TournamentService {
const [p1, p2] = result.split(':').map(Number);
await TournamentResult.create({ matchId, set, pointsPlayer1: p1, pointsPlayer2: p2 });
}
// Prüfe, ob ein Spieler die Gewinnsätze erreicht hat
// Lade Match neu, um sicherzustellen, dass wir den aktuellen Status haben
await match.reload();
if (!match.isFinished) {
const allResults = await TournamentResult.findAll({
where: { matchId },
order: [['set', 'ASC']]
});
let setsWonPlayer1 = 0;
let setsWonPlayer2 = 0;
for (const r of allResults) {
if (r.pointsPlayer1 > r.pointsPlayer2) {
setsWonPlayer1++;
} else if (r.pointsPlayer2 > r.pointsPlayer1) {
setsWonPlayer2++;
}
}
// Wenn ein Spieler die Gewinnsätze erreicht hat, schließe das Match automatisch ab
if (setsWonPlayer1 >= winningSets || setsWonPlayer2 >= winningSets) {
await this.finishMatch(userToken, clubId, tournamentId, matchId);
}
}
}
async finishMatch(userToken, clubId, tournamentId, matchId) {
@@ -572,6 +707,18 @@ class TournamentService {
});
}
async updateParticipantSeeded(userToken, clubId, tournamentId, participantId, seeded) {
await checkAccess(userToken, clubId);
const participant = await TournamentMember.findOne({
where: { id: participantId, tournamentId }
});
if (!participant) {
throw new Error('Teilnehmer nicht gefunden');
}
participant.seeded = seeded;
await participant.save();
}
// services/tournamentService.js
async deleteMatchResult(userToken, clubId, tournamentId, matchId, setToDelete) {
await checkAccess(userToken, clubId);
@@ -614,6 +761,28 @@ class TournamentService {
await match.save();
}
async setMatchActive(userToken, clubId, tournamentId, matchId, isActive) {
await checkAccess(userToken, clubId);
const match = await TournamentMatch.findOne({
where: { id: matchId, tournamentId }
});
if (!match) {
throw new Error("Match nicht gefunden");
}
// Wenn ein Match als aktiv gesetzt wird, setze alle anderen Matches des Turniers auf inaktiv
if (isActive) {
await TournamentMatch.update(
{ isActive: false },
{ where: { tournamentId, id: { [Op.ne]: matchId } } }
);
}
match.isActive = isActive;
await match.save();
}
async resetKnockout(userToken, clubId, tournamentId) {
await checkAccess(userToken, clubId);
// lösche alle Matches außer Gruppenphase