feat(kaisertisch): implement Kaisertisch tournament functionality with model, routes, and controller
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s

This commit is contained in:
Torsten Schulz (local)
2026-08-19 14:02:12 +02:00
parent d251d40868
commit 18ba120927
7 changed files with 191 additions and 27 deletions

View File

@@ -0,0 +1,59 @@
import { DiaryDate, KaisertischTournament } from '../models/index.js';
import HttpError from '../exceptions/HttpError.js';
const normalizeState = (state) => {
if (!state || typeof state !== 'object' || Array.isArray(state)) throw new HttpError('Ungültiger Kaisertisch-Stand', 400);
const tableCount = Math.min(30, Math.max(1, Number(state.tableCount) || 4));
const tables = Array.isArray(state.tables) ? state.tables.slice(0, tableCount).map((table) => ({ left: table?.left || null, right: table?.right || null })) : [];
return {
tableCount,
tables: tables.length === tableCount ? tables : Array.from({ length: tableCount }, () => ({ left: null, right: null })),
history: Array.isArray(state.history) ? state.history.slice(0, 500) : [],
round: Math.max(0, Number(state.round) || 0),
pendingWinners: state.pendingWinners && typeof state.pendingWinners === 'object' ? state.pendingWinners : {},
};
};
export const listKaisertischTournaments = async (req, res) => {
try {
const tournaments = await KaisertischTournament.findAll({
where: { clubId: req.params.clubId },
include: [{ model: DiaryDate, as: 'diaryDate', attributes: ['id', 'date'] }],
order: [[{ model: DiaryDate, as: 'diaryDate' }, 'date', 'DESC'], ['updatedAt', 'DESC']],
});
res.json(tournaments.map((tournament) => ({
diaryDateId: tournament.diaryDateId,
date: tournament.diaryDate?.date || null,
roundCount: Array.isArray(tournament.state?.history) ? tournament.state.history.length : 0,
updatedAt: tournament.updatedAt,
})));
} catch (error) {
console.error('[listKaisertischTournaments]', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turniere konnten nicht geladen werden.' });
}
};
export const getKaisertischTournament = async (req, res) => {
try {
const tournament = await KaisertischTournament.findOne({ where: { clubId: req.params.clubId, diaryDateId: req.params.dateId } });
if (!tournament) return res.status(404).json({ error: 'Kein Kaisertisch-Turnier für diesen Trainingstag gefunden.' });
res.json({ diaryDateId: tournament.diaryDateId, state: tournament.state, updatedAt: tournament.updatedAt });
} catch (error) {
console.error('[getKaisertischTournament]', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turnier konnte nicht geladen werden.' });
}
};
export const saveKaisertischTournament = async (req, res) => {
try {
const { clubId, dateId } = req.params;
const diaryDate = await DiaryDate.findOne({ where: { id: dateId, clubId } });
if (!diaryDate) throw new HttpError('Trainingstag nicht gefunden.', 404);
const state = normalizeState(req.body?.state);
const [tournament] = await KaisertischTournament.upsert({ clubId, diaryDateId: dateId, state });
res.status(200).json({ diaryDateId: Number(dateId), state: tournament?.state || state });
} catch (error) {
console.error('[saveKaisertischTournament]', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Kaisertisch-Turnier konnte nicht gespeichert werden.' });
}
};

View File

@@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS kaisertisch_tournaments (
id INT AUTO_INCREMENT PRIMARY KEY,
club_id INT NOT NULL,
diary_date_id INT NOT NULL,
state JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_kaisertisch_tournament_club_date (club_id, diary_date_id),
CONSTRAINT fk_kaisertisch_tournament_club FOREIGN KEY (club_id) REFERENCES clubs(id) ON DELETE CASCADE,
CONSTRAINT fk_kaisertisch_tournament_date FOREIGN KEY (diary_date_id) REFERENCES diary_dates(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,18 @@
import { DataTypes } from 'sequelize';
import sequelize from '../database.js';
import Club from './Club.js';
import DiaryDate from './DiaryDates.js';
const KaisertischTournament = sequelize.define('KaisertischTournament', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
clubId: { type: DataTypes.INTEGER, allowNull: false, field: 'club_id', references: { model: Club, key: 'id' }, onDelete: 'CASCADE' },
diaryDateId: { type: DataTypes.INTEGER, allowNull: false, field: 'diary_date_id', references: { model: DiaryDate, key: 'id' }, onDelete: 'CASCADE' },
state: { type: DataTypes.JSON, allowNull: false },
}, {
tableName: 'kaisertisch_tournaments',
underscored: true,
timestamps: true,
indexes: [{ unique: true, fields: ['club_id', 'diary_date_id'] }],
});
export default KaisertischTournament;

View File

@@ -98,6 +98,7 @@ import MemberProfileChangeRequest from './MemberProfileChangeRequest.js';
import MemberEventResponse from './MemberEventResponse.js';
import NotificationEvent from './NotificationEvent.js';
import NotificationRecipient from './NotificationRecipient.js';
import KaisertischTournament from './KaisertischTournament.js';
import TournamentSuggestion from './TournamentSuggestion.js';
// Official tournaments relations
OfficialTournament.hasMany(OfficialCompetition, { foreignKey: 'tournamentId', as: 'competitions' });
@@ -131,6 +132,10 @@ TournamentSuggestion.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
DiaryDate.belongsTo(Club, { foreignKey: 'clubId' });
Club.hasMany(DiaryDate, { foreignKey: 'clubId' });
DiaryDate.hasOne(KaisertischTournament, { foreignKey: 'diaryDateId', as: 'kaisertischTournament' });
KaisertischTournament.belongsTo(DiaryDate, { foreignKey: 'diaryDateId', as: 'diaryDate' });
Club.hasMany(KaisertischTournament, { foreignKey: 'clubId', as: 'kaisertischTournaments' });
KaisertischTournament.belongsTo(Club, { foreignKey: 'clubId', as: 'club' });
DiaryDate.belongsToMany(Member, { through: Participant, as: 'participants', foreignKey: 'diaryDateId' });
Member.belongsToMany(DiaryDate, { through: Participant, as: 'diaryDates', foreignKey: 'memberId' });
@@ -726,6 +731,7 @@ export {
MemberEventResponse,
NotificationEvent,
NotificationRecipient,
KaisertischTournament,
TournamentSuggestion,
ClubDistributionGroupMember,
};

View File

@@ -0,0 +1,10 @@
import express from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import { getKaisertischTournament, listKaisertischTournaments, saveKaisertischTournament } from '../controllers/kaisertischController.js';
const router = express.Router();
router.get('/:clubId', authenticate, authorize('diary', 'read'), listKaisertischTournaments);
router.get('/:clubId/:dateId', authenticate, authorize('diary', 'read'), getKaisertischTournament);
router.put('/:clubId/:dateId', authenticate, authorize('diary', 'write'), saveKaisertischTournament);
export default router;

View File

@@ -21,6 +21,7 @@ import {
import authRoutes from './routes/authRoutes.js';
import clubRoutes from './routes/clubRoutes.js';
import diaryRoutes from './routes/diaryRoutes.js';
import kaisertischRoutes from './routes/kaisertischRoutes.js';
import memberRoutes from './routes/memberRoutes.js';
import participantRoutes from './routes/participantRoutes.js';
import activityRoutes from './routes/activityRoutes.js';
@@ -339,6 +340,7 @@ app.use('/api/auth', authRoutes);
app.use('/api/clubs', clubRoutes);
app.use('/api/clubmembers', memberRoutes);
app.use('/api/diary', diaryRoutes);
app.use('/api/kaisertisch', kaisertischRoutes);
app.use('/api/participants', participantRoutes);
app.use('/api/activities', activityRoutes);
app.use('/api/membernotes', memberNoteRoutes);

View File

@@ -6,9 +6,9 @@
<h2>Kaisertisch</h2>
<p class="intro">Ein Sieg, ein Klick und der nächste Durchgang ist klar.</p>
</div>
<div class="session-status" :class="{ ready: occupiedSlots === tableCount * 2 }">
<div class="session-status" :class="{ ready: occupiedSlots === tableCount * 2, archived: isReadOnly }">
<span class="status-dot" aria-hidden="true"></span>
{{ occupiedSlots === tableCount * 2 ? `Runde ${round + 1} läuft` : `${occupiedSlots} von ${tableCount * 2} Plätzen belegt` }}
{{ isReadOnly ? 'Archiv · Nur ansehen' : (occupiedSlots === tableCount * 2 ? `Runde ${round + 1} läuft` : `${occupiedSlots} von ${tableCount * 2} Plätzen belegt`) }}
</div>
</header>
@@ -21,7 +21,8 @@
<span class="round-chip">{{ history.length ? `${history.length} Runden` : 'Noch keine Runde' }}</span>
</div>
<div v-if="activeTableIndexes.length" class="round-control" :class="{ complete: roundReady }">
<div v-if="isReadOnly" class="archive-notice" role="status"><strong>Archivansicht</strong><span>Dieses Turnier ist schreibgeschützt. Wähle Aktuelles Turnier, um weiterzuspielen.</span></div>
<div v-else-if="activeTableIndexes.length" class="round-control" :class="{ complete: roundReady }">
<div>
<strong>{{ pendingWinnerCount }} von {{ activeTableIndexes.length }} Ergebnissen erfasst</strong>
<span v-if="incompleteTableIndexes.length">Unvollständige Tische zuerst vollständig besetzen.</span>
@@ -44,7 +45,7 @@
<div class="table-surface">
<button
type="button" class="player-seat left" :class="seatClass(tableIndex, 'left')"
:aria-label="seatAriaLabel(tableIndex, 'left')" @click="handleSeatClick(tableIndex, 'left')"
:aria-label="seatAriaLabel(tableIndex, 'left')" :disabled="isReadOnly" @click="handleSeatClick(tableIndex, 'left')"
>
<span v-if="table.left" class="player-name">{{ playerName(table.left) }}</span>
<span v-else>{{ selectedSeat && selectedSeat.table === tableIndex && selectedSeat.side === 'left' ? 'Hier einsetzen' : 'Freier Platz' }}</span>
@@ -52,7 +53,7 @@
<span class="net" aria-hidden="true"></span>
<button
type="button" class="player-seat right" :class="seatClass(tableIndex, 'right')"
:aria-label="seatAriaLabel(tableIndex, 'right')" @click="handleSeatClick(tableIndex, 'right')"
:aria-label="seatAriaLabel(tableIndex, 'right')" :disabled="isReadOnly" @click="handleSeatClick(tableIndex, 'right')"
>
<span v-if="table.right" class="player-name">{{ playerName(table.right) }}</span>
<span v-else>{{ selectedSeat && selectedSeat.table === tableIndex && selectedSeat.side === 'right' ? 'Hier einsetzen' : 'Freier Platz' }}</span>
@@ -71,14 +72,14 @@
<div class="field-label" role="group" aria-label="Anzahl der Tische">
<span>Tische</span>
<div class="table-count-spinner">
<button type="button" aria-label="Einen Tisch weniger" :disabled="tableCount <= 1" @click="adjustTableCount(-1)"></button>
<button type="button" aria-label="Einen Tisch weniger" :disabled="isReadOnly || tableCount <= 1" @click="adjustTableCount(-1)"></button>
<output aria-live="polite">{{ tableCount }} {{ tableCount === 1 ? 'Tisch' : 'Tische' }}</output>
<button type="button" aria-label="Einen Tisch mehr" :disabled="tableCount >= 30" @click="adjustTableCount(1)">+</button>
<button type="button" aria-label="Einen Tisch mehr" :disabled="isReadOnly || tableCount >= 30" @click="adjustTableCount(1)">+</button>
</div>
</div>
<div class="setup-actions">
<button type="button" class="btn-primary" @click="createDemo"> Demo-Aufstellung</button>
<button type="button" class="btn-secondary" :class="{ active: swapMode }" @click="toggleSwapMode"> {{ swapMode ? 'Tausch wählen ' : 'Tauschen' }}</button>
<button type="button" class="btn-primary" :disabled="isReadOnly" @click="createDemo"> Demo-Aufstellung</button>
<button type="button" class="btn-secondary" :disabled="isReadOnly" :class="{ active: swapMode }" @click="toggleSwapMode"> {{ swapMode ? 'Tausch wählen ' : 'Tauschen' }}</button>
</div>
<p class="setup-note" v-if="swapMode">Zwei besetzte Plätze nacheinander antippen.</p>
<p class="setup-note" v-else-if="selectedSeat">{{ selectedSeatLabel }} ausgewählt. Nun einen Namen antippen.</p>
@@ -89,22 +90,41 @@
<div class="section-heading compact"><div><p class="eyebrow">ANWESEND</p><h3 id="pool-title">Teilnehmerpool <span>{{ availablePlayers.length }}</span></h3></div></div>
<div v-if="!trainingDates.length && !loading" class="empty-copy">Kein Trainingstag vorhanden. Die Demo-Aufstellung funktioniert trotzdem.</div>
<label v-else class="field-label">Trainingstag
<select v-model="selectedDateId" @change="loadParticipantsForDate">
<select :value="selectedDateId" :disabled="isReadOnly" @change="changeTrainingDate($event.target.value)">
<option value="">Trainingstag wählen</option>
<option v-for="date in trainingDates" :key="date.id" :value="String(date.id)">{{ formatDate(date.date) }}</option>
</select>
</label>
<div v-if="availablePlayers.length" class="participant-pool" aria-label="Nicht gesetzte Teilnehmende">
<button v-for="player in availablePlayers" :key="player.id" type="button" @click="placePlayer(player.id)">{{ player.name }}</button>
<button v-for="player in availablePlayers" :key="player.id" type="button" :disabled="isReadOnly" @click="placePlayer(player.id)">{{ player.name }}</button>
</div>
<p v-else class="empty-copy">{{ members.length ? 'Alle geladenen Teilnehmenden sind gesetzt.' : 'Für diesen Trainingstag sind keine anwesenden Teilnehmenden gemeldet.' }}</p>
</section>
<section class="utility-panel history-panel" aria-labelledby="history-title">
<div class="section-heading compact"><div><p class="eyebrow">VERLAUF</p><h3 id="history-title">Rundenhistorie</h3></div><button type="button" class="text-button" :disabled="!history.length" @click="undo"> Rückgängig</button></div>
<div class="section-heading compact"><div><p class="eyebrow">VERLAUF</p><h3 id="history-title">Rundenhistorie</h3></div><button type="button" class="text-button" :disabled="isReadOnly || !history.length" @click="undo"> Rückgängig</button></div>
<label class="archive-select">Turnier ansehen
<select v-model="selectedTournamentDateId" @change="selectTournament">
<option value="current">Aktuelles Turnier{{ selectedDateId ? ` · ${formatDateForSelector(selectedDateId)}` : '' }}</option>
<option v-for="tournament in archivedTournaments" :key="tournament.diaryDateId" :value="String(tournament.diaryDateId)">{{ formatDate(tournament.date) }} · {{ tournament.roundCount }} {{ tournament.roundCount === 1 ? 'Runde' : 'Runden' }}</option>
</select>
</label>
<p v-if="!archivedTournaments.length" class="archive-empty">Abgeschlossene Turniere erscheinen hier automatisch.</p>
<p v-if="history.length" class="latest-result"><strong>Zuletzt:</strong> {{ history[0].summary }}</p>
<p v-else class="empty-copy">Noch kein Ergebnis erfasst.</p>
<ol v-if="history.length" class="history-list"><li v-for="entry in history.slice(0, 5)" :key="entry.id"><span>Runde {{ entry.round }}</span><span>{{ entry.summary }}</span><time>{{ entry.time }}</time></li></ol>
<section v-if="playerPaths.length" class="player-paths" aria-labelledby="player-paths-title">
<h4 id="player-paths-title">Spielerwege</h4>
<div v-for="player in playerPaths" :key="player.id" class="player-path-row">
<strong>{{ player.name }}</strong>
<span class="player-path" :aria-label="`${player.name}: ${player.path.join(' nach ')}`">
<template v-for="(table, index) in player.path" :key="`${player.id}-${index}-${table}`">
<span v-if="index" class="path-arrow" aria-hidden="true"></span>
<span class="path-table">{{ table }}</span>
</template>
</span>
</div>
</section>
</section>
</aside>
@@ -131,11 +151,14 @@ const DEMO_PLAYERS = ['Mia Weber', 'Luca Schneider', 'Nora Fischer', 'Jonas Beck
export default {
name: 'KaisertischView',
data() {
return { loading: true, error: '', allMembers: [], members: [], trainingDates: [], selectedDateId: '', tableCount: 4, tables: [], history: [], round: 0, selectedSeat: null, swapMode: false, swapSelection: [], pendingWinners: {}, showRoundConfirm: false, participantRequestId: 0 };
return { loading: true, error: '', allMembers: [], members: [], trainingDates: [], selectedDateId: '', currentSessionDateId: '', selectedTournamentDateId: 'current', tournamentHistory: [], tableCount: 4, tables: [], history: [], round: 0, selectedSeat: null, swapMode: false, swapSelection: [], pendingWinners: {}, showRoundConfirm: false, participantRequestId: 0, persistenceTimer: null };
},
computed: {
...mapGetters(['currentClub', 'isAuthenticated']),
storageKey() { return `kaisertisch-session:${this.currentClub || 'none'}`; },
sessionStorageKey() { return `${this.storageKey}:${this.currentSessionDateId || 'current'}`; },
isReadOnly() { return this.selectedTournamentDateId !== 'current'; },
archivedTournaments() { return this.tournamentHistory.filter(tournament => String(tournament.diaryDateId) !== String(this.currentSessionDateId)); },
occupiedSlots() { return this.tables.reduce((count, table) => count + Number(Boolean(table.left)) + Number(Boolean(table.right)), 0); },
seatedIds() { return new Set(this.tables.flatMap(table => [table.left, table.right]).filter(Boolean)); },
availablePlayers() { return this.members.filter(member => !this.seatedIds.has(member.id)); },
@@ -155,36 +178,68 @@ export default {
return { tableIndex, label: this.tableLabel(tableIndex), players: players.map(id => this.playerName(id)).join(' · ') };
});
},
playerPaths() {
const snapshots = this.history.slice().reverse().map(entry => entry?.before).filter(Array.isArray);
if (!snapshots.length || !Array.isArray(this.tables)) return [];
const pathsByPlayer = new Map();
[...snapshots, this.tables].forEach(state => {
const seenInState = new Set();
state.forEach((table, tableIndex) => {
if (!table || typeof table !== 'object') return;
['left', 'right'].forEach(side => {
const playerId = table[side];
if (playerId === null || playerId === undefined || playerId === '') return;
const id = String(playerId);
if (seenInState.has(id)) return;
seenInState.add(id);
const path = pathsByPlayer.get(id) || [];
const label = this.tableLabel(tableIndex);
if (path[path.length - 1] !== label) path.push(label);
pathsByPlayer.set(id, path);
});
});
});
return [...pathsByPlayer.entries()].map(([id, path]) => ({ id, name: this.playerName(id), path }));
},
},
watch: {
tables: { deep: true, handler() { this.persist(); } }, tableCount() { this.persist(); }, history: { deep: true, handler() { this.persist(); } }, pendingWinners: { deep: true, handler() { this.persist(); } }, selectedDateId() { this.persist(); },
tables: { deep: true, handler() { this.persist(); } }, tableCount() { this.persist(); }, history: { deep: true, handler() { this.persist(); } }, pendingWinners: { deep: true, handler() { this.persist(); } },
currentClub() { this.loadData(); },
'$route.query.dateId': async function(dateId) {
if (!dateId || !this.trainingDates.some(date => String(date.id) === String(dateId))) return;
this.selectedDateId = String(dateId);
await this.loadParticipantsForDate();
await this.changeTrainingDate(String(dateId));
},
},
methods: {
makeTables(count = this.tableCount) { return Array.from({ length: count }, () => ({ left: null, right: null })); },
playerName(id) { const member = this.members.find(item => String(item.id) === String(id)); return member?.name || `${member?.firstName || ''} ${member?.lastName || ''}`.trim() || String(id); },
playerName(id) { const member = this.members.find(item => String(item.id) === String(id)) || this.allMembers.find(item => String(item.id) === String(id)); return member?.name || `${member?.firstName || ''} ${member?.lastName || ''}`.trim() || String(id); },
formatMember(member) { return { ...member, name: `${member.firstName || member.firstname || ''} ${member.lastName || member.lastname || ''}`.trim() || member.name || 'Unbekannt' }; },
formatDate(value) { return value ? new Intl.DateTimeFormat('de-DE', { weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(`${value}T12:00:00`)) : 'Unbekanntes Datum'; },
restore() { try { const saved = JSON.parse(safeLocalStorage.getItem(this.storageKey) || 'null'); if (!saved) return false; this.tableCount = Math.min(30, Math.max(1, Number(saved.tableCount) || 4)); this.tables = Array.isArray(saved.tables) && saved.tables.length === this.tableCount ? saved.tables : this.makeTables(); this.history = Array.isArray(saved.history) ? saved.history : []; this.round = Number(saved.round) || this.history.length; this.selectedDateId = saved.selectedDateId || ''; this.pendingWinners = saved.pendingWinners && typeof saved.pendingWinners === 'object' ? saved.pendingWinners : {}; return true; } catch (_) { return false; } },
persist() { if (!this.currentClub || !this.tables.length) return; safeLocalStorage.setItem(this.storageKey, JSON.stringify({ tableCount: this.tableCount, tables: this.tables, history: this.history, round: this.round, selectedDateId: this.selectedDateId, pendingWinners: this.pendingWinners })); },
async loadData() { this.loading = true; this.error = ''; try { if (!this.currentClub) return; const [membersResponse, datesResponse] = await Promise.all([apiClient.get(`/clubmembers/get/${this.currentClub}/false`), apiClient.get(`/diary/${this.currentClub}`)]); this.allMembers = (Array.isArray(membersResponse.data) ? membersResponse.data : []).map(this.formatMember); this.members = [...this.allMembers]; this.trainingDates = Array.isArray(datesResponse.data) ? datesResponse.data : []; const restored = this.restore(); if (!restored) this.tables = this.makeTables(); const requestedDateId = this.$route?.query?.dateId; if (requestedDateId && this.trainingDates.some(date => String(date.id) === String(requestedDateId))) this.selectedDateId = String(requestedDateId); if (this.selectedDateId) await this.loadParticipantsForDate(); else { const today = new Date().toISOString().slice(0, 10); const todayEntry = this.trainingDates.find(date => date.date === today); if (todayEntry) { this.selectedDateId = String(todayEntry.id); await this.loadParticipantsForDate(); } } } catch (error) { this.error = 'Die Trainingsdaten konnten nicht geladen werden. Deine lokale Aufstellung bleibt erhalten.'; if (!this.tables.length) this.tables = this.makeTables(); } finally { this.loading = false; } },
formatDateForSelector(id) { return this.formatDate(this.trainingDates.find(date => String(date.id) === String(id))?.date); },
serializedState() { return { tableCount: this.tableCount, tables: this.tables.map(table => ({ ...table })), history: this.history.map(entry => ({ ...entry, before: Array.isArray(entry.before) ? entry.before.map(table => ({ ...table })) : [] })), round: this.round, pendingWinners: { ...this.pendingWinners } }; },
applyState(saved) { const tableCount = Math.min(30, Math.max(1, Number(saved?.tableCount) || 4)); this.tableCount = tableCount; this.tables = Array.isArray(saved?.tables) && saved.tables.length === tableCount ? saved.tables.map(table => ({ left: table?.left || null, right: table?.right || null })) : this.makeTables(tableCount); this.history = Array.isArray(saved?.history) ? saved.history : []; this.round = Number(saved?.round) || this.history.length; this.pendingWinners = saved?.pendingWinners && typeof saved.pendingWinners === 'object' ? saved.pendingWinners : {}; this.selectedSeat = null; this.swapMode = false; this.swapSelection = []; },
localState(dateId = this.currentSessionDateId) { try { return JSON.parse(safeLocalStorage.getItem(`${this.storageKey}:${dateId || 'current'}`) || 'null'); } catch (_) { return null; } },
persist() { if (!this.currentClub || !this.tables.length || this.isReadOnly) return; const state = this.serializedState(); safeLocalStorage.setItem(this.sessionStorageKey, JSON.stringify(state)); safeLocalStorage.setItem(this.storageKey, JSON.stringify({ ...state, selectedDateId: this.selectedDateId })); if (!this.currentSessionDateId) return; clearTimeout(this.persistenceTimer); this.persistenceTimer = setTimeout(() => this.persistRemote(state, this.currentSessionDateId), 500); },
async persistRemote(state, dateId) { try { await apiClient.put(`/kaisertisch/${this.currentClub}/${dateId}`, { state }); await this.loadTournamentHistory(); } catch (_) { /* local storage remains the offline and pre-migration fallback */ } },
async loadTournamentHistory() { if (!this.currentClub) return; try { const response = await apiClient.get(`/kaisertisch/${this.currentClub}`); const remote = Array.isArray(response.data) ? response.data : []; const local = this.trainingDates.map(date => { const state = this.localState(date.id); return state?.history?.length ? { diaryDateId: date.id, date: date.date, roundCount: state.history.length, local: true } : null; }).filter(Boolean); const byDate = new Map([...local, ...remote].map(item => [String(item.diaryDateId), item])); this.tournamentHistory = [...byDate.values()].sort((a, b) => String(b.date || '').localeCompare(String(a.date || ''))); } catch (_) { const local = this.trainingDates.map(date => { const state = this.localState(date.id); return state?.history?.length ? { diaryDateId: date.id, date: date.date, roundCount: state.history.length, local: true } : null; }).filter(Boolean); this.tournamentHistory = local.sort((a, b) => String(b.date).localeCompare(String(a.date))); } },
async loadSession(dateId, preferRemote = true) { let state = null; if (dateId && preferRemote) { try { const response = await apiClient.get(`/kaisertisch/${this.currentClub}/${dateId}`); state = response.data?.state; } catch (_) { /* a 404 means the tournament has only local data or has not started */ } } state ||= this.localState(dateId); this.applyState(state); },
async changeTrainingDate(dateId) { if (this.isReadOnly || String(dateId) === String(this.selectedDateId)) return; this.persist(); this.selectedDateId = String(dateId || ''); this.currentSessionDateId = this.selectedDateId; await this.loadParticipantsForDate(); await this.loadSession(this.currentSessionDateId); await this.loadTournamentHistory(); },
async selectTournament() { if (this.selectedTournamentDateId === 'current') { await this.loadSession(this.currentSessionDateId); return; } const dateId = this.selectedTournamentDateId; await this.loadSession(dateId); },
async loadData() { this.loading = true; this.error = ''; try { if (!this.currentClub) return; const [membersResponse, datesResponse] = await Promise.all([apiClient.get(`/clubmembers/get/${this.currentClub}/false`), apiClient.get(`/diary/${this.currentClub}`)]); this.allMembers = (Array.isArray(membersResponse.data) ? membersResponse.data : []).map(this.formatMember); this.members = [...this.allMembers]; this.trainingDates = Array.isArray(datesResponse.data) ? datesResponse.data : []; let legacyState = null; try { legacyState = JSON.parse(safeLocalStorage.getItem(this.storageKey) || 'null'); } catch (_) { /* no legacy session */ } const requestedDateId = this.$route?.query?.dateId; const defaultDate = requestedDateId && this.trainingDates.some(date => String(date.id) === String(requestedDateId)) ? String(requestedDateId) : this.trainingDates.find(date => date.date === new Date().toISOString().slice(0, 10))?.id; this.selectedDateId = defaultDate ? String(defaultDate) : ''; this.currentSessionDateId = this.selectedDateId; if (this.currentSessionDateId && legacyState && !this.localState(this.currentSessionDateId)) safeLocalStorage.setItem(this.sessionStorageKey, JSON.stringify(legacyState)); if (this.selectedDateId) await this.loadParticipantsForDate(); await this.loadSession(this.currentSessionDateId); await this.loadTournamentHistory(); } catch (error) { this.error = 'Die Trainingsdaten konnten nicht geladen werden. Deine lokale Aufstellung bleibt erhalten.'; if (!this.tables.length) this.tables = this.makeTables(); } finally { this.loading = false; } },
async loadParticipantsForDate() { if (!this.selectedDateId) return; const dateId = String(this.selectedDateId); const requestId = ++this.participantRequestId; try { const response = await apiClient.get(`/participants/${dateId}`); if (requestId !== this.participantRequestId || dateId !== String(this.selectedDateId)) return; const presentIds = new Set((Array.isArray(response.data) ? response.data : []).filter(participant => !participant.attendanceStatus || participant.attendanceStatus === 'present').map(participant => String(participant.memberId))); this.members = this.allMembers.filter(member => presentIds.has(String(member.id)) || this.seatedIds.has(member.id)); } catch (_) { if (requestId === this.participantRequestId) this.error = 'Teilnehmende dieses Trainingstags konnten nicht geladen werden.'; } },
adjustTableCount(direction) { const nextCount = Math.min(30, Math.max(1, this.tableCount + direction)); if (nextCount === this.tableCount) return; this.tableCount = nextCount; this.changeTableCount(); },
adjustTableCount(direction) { if (this.isReadOnly) return; const nextCount = Math.min(30, Math.max(1, this.tableCount + direction)); if (nextCount === this.tableCount) return; this.tableCount = nextCount; this.changeTableCount(); },
changeTableCount() { const old = this.tables; this.tables = this.makeTables(this.tableCount).map((table, index) => old[index] || table); this.selectedSeat = null; this.swapSelection = []; this.pendingWinners = {}; },
createDemo() { const names = this.members.length ? this.members.map(member => ({ id: member.id, name: member.name })) : DEMO_PLAYERS.map((name, index) => ({ id: `demo-${index}`, name })); if (!this.members.length) this.members = names; this.tables = this.makeTables().map((table, index) => ({ left: names[index * 2]?.id || null, right: names[index * 2 + 1]?.id || null })); this.history = []; this.round = 0; this.selectedSeat = null; this.pendingWinners = {}; },
createDemo() { if (this.isReadOnly) return; const names = this.members.length ? this.members.map(member => ({ id: member.id, name: member.name })) : DEMO_PLAYERS.map((name, index) => ({ id: `demo-${index}`, name })); if (!this.members.length) this.members = names; this.tables = this.makeTables().map((table, index) => ({ left: names[index * 2]?.id || null, right: names[index * 2 + 1]?.id || null })); this.history = []; this.round = 0; this.selectedSeat = null; this.pendingWinners = {}; },
seatClass(table, side) { return { empty: !this.tables[table][side], selected: this.selectedSeat?.table === table && this.selectedSeat?.side === side, 'swap-selected': this.swapSelection.some(item => item.table === table && item.side === side), winner: this.pendingWinners[table] === this.tables[table][side] }; },
seatAriaLabel(table, side) { const value = this.tables[table][side]; return value ? `${this.playerName(value)} an ${this.tableLabel(table)}${this.pendingWinners[table] === value ? ', als Sieger markiert' : ', als Sieger auswählen'}` : `Freier ${side === 'left' ? 'linker' : 'rechter'} Platz an ${this.tableLabel(table)}`; },
handleSeatClick(table, side) { const player = this.tables[table][side]; if (this.swapMode) { if (!player) return; const selection = { table, side }; if (this.swapSelection.some(item => item.table === table && item.side === side)) { this.swapSelection = []; return; } this.swapSelection.push(selection); if (this.swapSelection.length === 2) { const [first, second] = this.swapSelection; [this.tables[first.table][first.side], this.tables[second.table][second.side]] = [this.tables[second.table][second.side], this.tables[first.table][first.side]]; this.swapSelection = []; this.swapMode = false; this.pendingWinners = {}; } return; } if (!player) { this.selectedSeat = { table, side }; return; } if (!this.tables[table].left || !this.tables[table].right) { this.selectedSeat = { table, side }; return; } this.pendingWinners = { ...this.pendingWinners, [table]: player }; },
placePlayer(id) { if (!this.selectedSeat) return; this.tables[this.selectedSeat.table][this.selectedSeat.side] = id; this.selectedSeat = null; this.pendingWinners = {}; },
toggleSwapMode() { this.swapMode = !this.swapMode; this.swapSelection = []; this.selectedSeat = null; },
handleSeatClick(table, side) { if (this.isReadOnly) return; const player = this.tables[table][side]; if (this.swapMode) { if (!player) return; const selection = { table, side }; if (this.swapSelection.some(item => item.table === table && item.side === side)) { this.swapSelection = []; return; } this.swapSelection.push(selection); if (this.swapSelection.length === 2) { const [first, second] = this.swapSelection; [this.tables[first.table][first.side], this.tables[second.table][second.side]] = [this.tables[second.table][second.side], this.tables[first.table][first.side]]; this.swapSelection = []; this.swapMode = false; this.pendingWinners = {}; } return; } if (!player) { this.selectedSeat = { table, side }; return; } if (!this.tables[table].left || !this.tables[table].right) { this.selectedSeat = { table, side }; return; } this.pendingWinners = { ...this.pendingWinners, [table]: player }; },
placePlayer(id) { if (this.isReadOnly || !this.selectedSeat) return; this.tables[this.selectedSeat.table][this.selectedSeat.side] = id; this.selectedSeat = null; this.pendingWinners = {}; },
toggleSwapMode() { if (this.isReadOnly) return; this.swapMode = !this.swapMode; this.swapSelection = []; this.selectedSeat = null; },
tableLabel(index) { return index === 0 ? 'Kaisertisch' : `Tisch ${index + 1}`; },
applyRound() { if (!this.roundReady) return; const snapshot = this.tables.map(table => ({ ...table })); const indexes = this.activeTableIndexes; const oldTables = snapshot.map(table => ({ ...table })); const winner = index => this.pendingWinners[index]; const loser = index => oldTables[index].left === winner(index) ? oldTables[index].right : oldTables[index].left; if (indexes.length > 1) { indexes.forEach((tableIndex, position) => { const nextPlayers = position === 0 ? [winner(indexes[0]), winner(indexes[1])] : position === indexes.length - 1 ? [loser(indexes[position - 1]), loser(indexes[position])] : [loser(indexes[position - 1]), winner(indexes[position + 1])]; this.tables[tableIndex] = { left: nextPlayers[0], right: nextPlayers[1] }; }); } this.round += 1; const summary = indexes.map(index => `${this.tableLabel(index)}: ${this.playerName(winner(index))}`).join(' · '); this.history.unshift({ id: `${Date.now()}-${this.round}`, round: this.round, time: new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(new Date()), summary, before: snapshot }); this.pendingWinners = {}; this.showRoundConfirm = false; },
undo() { const previous = this.history.shift(); if (!previous) return; this.tables = previous.before.map(table => ({ ...table })); this.round = Math.max(0, this.round - 1); this.pendingWinners = {}; this.showRoundConfirm = false; },
applyRound() { if (this.isReadOnly || !this.roundReady) return; const snapshot = this.tables.map(table => ({ ...table })); const indexes = this.activeTableIndexes; const oldTables = snapshot.map(table => ({ ...table })); const winner = index => this.pendingWinners[index]; const loser = index => oldTables[index].left === winner(index) ? oldTables[index].right : oldTables[index].left; if (indexes.length > 1) { indexes.forEach((tableIndex, position) => { const nextPlayers = position === 0 ? [winner(indexes[0]), winner(indexes[1])] : position === indexes.length - 1 ? [loser(indexes[position - 1]), loser(indexes[position])] : [loser(indexes[position - 1]), winner(indexes[position + 1])]; this.tables[tableIndex] = { left: nextPlayers[0], right: nextPlayers[1] }; }); } this.round += 1; const summary = indexes.map(index => `${this.tableLabel(index)}: ${this.playerName(winner(index))}`).join(' · '); this.history.unshift({ id: `${Date.now()}-${this.round}`, round: this.round, time: new Intl.DateTimeFormat('de-DE', { hour: '2-digit', minute: '2-digit' }).format(new Date()), summary, before: snapshot }); this.pendingWinners = {}; this.showRoundConfirm = false; },
undo() { if (this.isReadOnly) return; const previous = this.history.shift(); if (!previous) return; this.tables = previous.before.map(table => ({ ...table })); this.round = Math.max(0, this.round - 1); this.pendingWinners = {}; this.showRoundConfirm = false; },
},
mounted() { this.loadData(); },
};
@@ -200,4 +255,7 @@ export default {
.net { background:#e8ebde; border-top:3px dashed #58755e; border-bottom:3px dashed #58755e; }
.round-control { display:flex;align-items:center;justify-content:space-between;gap:1rem;margin-top:1rem;padding:.75rem .85rem;border:1px solid #e2c79e;background:#fff6e6;color:#75552c; }.round-control strong,.round-control span { display:block; }.round-control strong { font-size:.9rem; }.round-control span { margin-top:.14rem;font-size:.77rem; }.round-control.complete { background:#eaf7ea;border-color:#a9d9ae;color:#21683b; }.round-apply:disabled { background:#b6c2b5;border-color:#aab5a9;color:#fff;cursor:not-allowed; }.player-seat.winner { background:#66c96a;color:#123d24;box-shadow:inset 0 0 0 3px #eaffdd; }.player-seat.winner::after { content:'✓ Sieger';display:block;font-size:.68rem;letter-spacing:.04em;margin-top:.22rem;font-weight:900; }
.session-status.archived { color:#694a22; background:#f6e9d5; border-color:#dfc198; }.archive-notice { display:grid;gap:.2rem;margin-top:1rem;padding:.75rem .85rem;border-left:4px solid #a07040;background:#fff4e3;color:#684722;font-size:.82rem; }.player-seat:disabled { cursor:default; }.player-seat:hover:disabled { background:transparent; }.btn-primary:disabled,.btn-secondary:disabled,.participant-pool button:disabled { opacity:.52;cursor:not-allowed; }.archive-select { display:grid;gap:.34rem;margin-top:.8rem;font-size:.77rem;font-weight:800;color:#dac8ae; }.archive-select select { width:100%;padding:.55rem .6rem;border:1px solid rgba(255,255,255,.28);border-radius:4px;background:#43534b;color:#fff;font:inherit; }.archive-empty { margin:.45rem 0 0;font-size:.75rem;line-height:1.4;color:#c6d0c7; }
.player-paths { margin-top:1rem;padding-top:.8rem;border-top:1px solid rgba(255,255,255,.2); }.player-paths h4 { margin:0 0 .55rem;color:#f1c686;font-size:.77rem;letter-spacing:.08em;text-transform:uppercase; }.player-path-row { display:grid;grid-template-columns:minmax(84px,.8fr) minmax(0,1.7fr);gap:.45rem .65rem;padding:.45rem 0;border-top:1px solid rgba(255,255,255,.1);font-size:.78rem;line-height:1.4; }.player-path-row strong { color:#fff;font-weight:800; }.player-path { display:flex;flex-wrap:wrap;align-items:center;gap:.25rem;color:#dce6dc; }.path-table { white-space:nowrap; }.path-arrow { color:#f1c686;font-weight:900; }
@media (max-width:580px) { .player-path-row { grid-template-columns:1fr;gap:.15rem; } }
</style>