feat: implement TTR preview functionality with caching and UI integration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s
This commit is contained in:
@@ -178,6 +178,11 @@
|
||||
<span v-if="isMatchCompleted" class="locked-indicator">🔒</span>
|
||||
</button>
|
||||
|
||||
<button class="section-btn" :class="{ active: activeSection === 'ttr', disabled: !canOpenTtrPreview }" :disabled="!canOpenTtrPreview" @click="openTtrSection" title="Wird nach Signatur der Gastaufstellung geladen">
|
||||
<div class="section-icon">📈</div>
|
||||
<span>TTR</span>
|
||||
</button>
|
||||
|
||||
<button class="section-btn" :class="{ active: activeSection === 'completion', disabled: !canOpenCompletionStage }"
|
||||
@click="setActiveSection('completion')" :disabled="!canOpenCompletionStage">
|
||||
<div class="section-icon">✅</div>
|
||||
@@ -492,7 +497,7 @@
|
||||
<span class="score-value">{{ getOverallScore() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="result-table-wrap" tabindex="0" aria-label="Ergebnistabelle horizontal scrollen">
|
||||
<table class="result-table">
|
||||
<thead>
|
||||
@@ -612,6 +617,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'ttr'" class="ttr-preview" aria-label="TTR-Vorschau">
|
||||
<div class="ttr-preview-heading">
|
||||
<div><h3>TTR-Vorschau</h3><p>Automatisch aus den gespeicherten Vereinswerten und – falls nötig – der myTischtennis-Rangliste geladen. Doppel zählen nicht.</p></div>
|
||||
<button type="button" class="btn-secondary" :disabled="ttrLoading" @click="loadTtrPreview">{{ ttrLoading ? 'Lade …' : 'Aktualisieren' }}</button>
|
||||
</div>
|
||||
<p v-if="ttrError" class="ttr-preview-empty">{{ ttrError }}</p>
|
||||
<div v-else-if="ttrLoading" class="ttr-preview-empty">TTR-Werte werden geladen …</div>
|
||||
<div v-else class="ttr-preview-table-wrap">
|
||||
<table class="ttr-preview-table"><thead><tr><th>Einzel</th><th>Heim</th><th>Gast</th><th>Erwartung Heim</th><th>Vorschau / Ergebnis</th></tr></thead>
|
||||
<tbody><tr v-for="entry in ttrSingles" :key="`ttr-${entry.index}`"><td>{{ entry.index + 1 }}</td><td>{{ entry.homeName }} · {{ ttrSnapshot(entry.index).homeTtr }}</td><td>{{ entry.guestName }} · {{ ttrSnapshot(entry.index).guestTtr }}</td><td>{{ formatTtrProbability(entry.preview.homeWin.expectedWinProbability) }}</td><td v-if="entry.actual">{{ entry.actual.winner === 'home' ? 'Heimsieg' : 'Gastsieg' }}: Heim {{ formatTtrChange(entry.actual.home.displayChange) }}, Gast {{ formatTtrChange(entry.actual.guest.displayChange) }}</td><td v-else>Heimsieg: {{ formatTtrChange(entry.preview.homeWin.displayChange) }} · Heimpleite: {{ formatTtrChange(entry.preview.homeLoss.displayChange) }}</td></tr></tbody></table>
|
||||
</div>
|
||||
<p v-if="ttrUnavailablePlayers.length" class="ttr-preview-empty">Noch ohne Wert: {{ ttrUnavailablePlayers.join(', ') }}.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="activeSection === 'completion'" class="completion-content">
|
||||
<!-- Aufstellungen -->
|
||||
<div class="lineups-summary">
|
||||
@@ -840,6 +859,7 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
import apiClient, { backendBaseUrl } from '../apiClient';
|
||||
import { onMatchReportSubmitted, offMatchReportSubmitted } from '../services/socketService.js';
|
||||
import { calculateTtrChange } from '../services/ttrCalculator.mjs';
|
||||
|
||||
export default {
|
||||
name: 'MatchReportDialog',
|
||||
@@ -916,6 +936,12 @@ export default {
|
||||
pinModalError: '',
|
||||
// Ergebnisse für Ergebniserfassung
|
||||
results: [],
|
||||
// Kept separately from nuScore results: these are local event
|
||||
// snapshots and must never alter the official match payload.
|
||||
ttrSnapshots: {},
|
||||
ttrPlayers: [],
|
||||
ttrLoading: false,
|
||||
ttrError: '',
|
||||
// Fehlermeldungen für Satzeingaben
|
||||
errors: [],
|
||||
// Aktive Zelle der schwebenden Satz-Tastatur: { matchIndex, setIndex } oder null
|
||||
@@ -1031,6 +1057,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
}
|
||||
return this.areBothLineupsCertified();
|
||||
},
|
||||
canOpenTtrPreview() {
|
||||
// Die Gastaufstellung ist der verlässliche Zeitpunkt, zu dem beide
|
||||
// tatsächlichen Einzel-Aufstellungen feststehen.
|
||||
return this.isGuestLineupCertified || this.viewOnly;
|
||||
},
|
||||
canOpenCompletionStage() {
|
||||
if (this.viewOnly) return true;
|
||||
if (this.isMatchCompleted) {
|
||||
@@ -1096,6 +1127,34 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
|
||||
return !this.areAllMatchResultsValid() || !this.areStartAndEndTimesValid();
|
||||
},
|
||||
ttrSingles() {
|
||||
return this.results
|
||||
.map((match, index) => ({ match, index }))
|
||||
.filter(({ match }) => !this.isTtrDouble(match))
|
||||
.map(({ match, index }) => {
|
||||
const snapshot = this.ttrSnapshot(index);
|
||||
if (!Number.isFinite(Number(snapshot?.homeTtr)) || !Number.isFinite(Number(snapshot?.guestTtr))) return null;
|
||||
const homeWin = calculateTtrChange(snapshot.homeTtr, snapshot.guestTtr, 'win', snapshot.homeAdjustmentConstant);
|
||||
const homeLoss = calculateTtrChange(snapshot.homeTtr, snapshot.guestTtr, 'loss', snapshot.homeAdjustmentConstant);
|
||||
const guestWin = calculateTtrChange(snapshot.guestTtr, snapshot.homeTtr, 'win', snapshot.guestAdjustmentConstant);
|
||||
const guestLoss = calculateTtrChange(snapshot.guestTtr, snapshot.homeTtr, 'loss', snapshot.guestAdjustmentConstant);
|
||||
const winner = this.ttrWinner(match);
|
||||
return {
|
||||
index,
|
||||
homeName: match.homeName || 'Heim',
|
||||
guestName: match.guestName || 'Gast',
|
||||
preview: { homeWin, homeLoss, guestWin, guestLoss },
|
||||
actual: winner ? {
|
||||
winner,
|
||||
home: winner === 'home' ? homeWin : homeLoss,
|
||||
guest: winner === 'guest' ? guestWin : guestLoss,
|
||||
} : null,
|
||||
};
|
||||
}).filter(Boolean);
|
||||
},
|
||||
ttrUnavailablePlayers() {
|
||||
return this.ttrPlayers.filter((player) => !Number.isFinite(Number(player.ttr))).map((player) => player.name);
|
||||
},
|
||||
draftStatusState() {
|
||||
if (this.isDraftSyncing) {
|
||||
return 'syncing';
|
||||
@@ -3220,6 +3279,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
completed: false,
|
||||
result: '1:0'
|
||||
}));
|
||||
this.ensureTtrSnapshots();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3232,6 +3292,75 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
completed: false,
|
||||
result: '1:0'
|
||||
}));
|
||||
this.ensureTtrSnapshots();
|
||||
},
|
||||
ensureTtrSnapshots() {
|
||||
const next = {};
|
||||
this.results.forEach((match, index) => {
|
||||
if (this.isTtrDouble(match)) return;
|
||||
if (this.ttrSnapshots[index]) next[index] = this.ttrSnapshots[index];
|
||||
});
|
||||
this.ttrSnapshots = next;
|
||||
},
|
||||
ttrSnapshot(index) {
|
||||
return this.ttrSnapshots[index] || null;
|
||||
},
|
||||
normalizeTtrName(value) {
|
||||
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
|
||||
},
|
||||
ttrLineupPlayers(side) {
|
||||
const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
|
||||
return (this.meetingDetails?.[key] || []).filter((player) => player?.isSelected && player?.nuLigaPersonId > 0 && this.isSingleLineupPlayer(player));
|
||||
},
|
||||
async openTtrSection() {
|
||||
if (!this.canOpenTtrPreview) return;
|
||||
this.activeSection = 'ttr';
|
||||
await this.loadTtrPreview();
|
||||
},
|
||||
async loadTtrPreview() {
|
||||
if (!this.canOpenTtrPreview || !this.match?.id || !this.meetingDetails) return;
|
||||
this.ttrLoading = true;
|
||||
this.ttrError = '';
|
||||
try {
|
||||
const response = await apiClient.post(`/matches/${this.match.id}/ttr-preview`, {
|
||||
homePlayers: this.ttrLineupPlayers('home'),
|
||||
guestPlayers: this.ttrLineupPlayers('guest'),
|
||||
homeTeamName: this.meetingData?.homeTeamname,
|
||||
guestTeamName: this.meetingData?.guestTeamname,
|
||||
});
|
||||
this.ttrPlayers = Array.isArray(response.data?.players) ? response.data.players : [];
|
||||
const index = new Map(this.ttrPlayers.map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player]));
|
||||
const snapshots = {};
|
||||
this.results.forEach((match, resultIndex) => {
|
||||
if (this.isTtrDouble(match)) return;
|
||||
const home = index.get(`home:${this.normalizeTtrName(match.homeName)}`);
|
||||
const guest = index.get(`guest:${this.normalizeTtrName(match.guestName)}`);
|
||||
if (!Number.isFinite(Number(home?.ttr)) || !Number.isFinite(Number(guest?.ttr))) return;
|
||||
snapshots[resultIndex] = { homeTtr: Number(home.ttr), guestTtr: Number(guest.ttr), homeAdjustmentConstant: Number(home.adjustmentConstant || 16), guestAdjustmentConstant: Number(guest.adjustmentConstant || 16) };
|
||||
});
|
||||
this.ttrSnapshots = snapshots;
|
||||
} catch (error) {
|
||||
this.ttrError = error.response?.data?.error || 'TTR-Werte konnten nicht geladen werden.';
|
||||
} finally {
|
||||
this.ttrLoading = false;
|
||||
}
|
||||
},
|
||||
isTtrDouble(match) {
|
||||
const label = String(match?.label || '').toUpperCase();
|
||||
return label.includes('D') || String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
|
||||
},
|
||||
ttrWinner(match) {
|
||||
if (!match?.completed) return null;
|
||||
const homeWins = this.getPlayerSetWins(match, 'home');
|
||||
const guestWins = this.getPlayerSetWins(match, 'guest');
|
||||
if (homeWins === guestWins) return null;
|
||||
return homeWins > guestWins ? 'home' : 'guest';
|
||||
},
|
||||
formatTtrProbability(value) {
|
||||
return `${(value * 100).toFixed(1)} %`;
|
||||
},
|
||||
formatTtrChange(value) {
|
||||
return `${value > 0 ? '+' : ''}${value}`;
|
||||
},
|
||||
populateResultsFromMeetingDetails() {
|
||||
if (!this.meetingDetails || !Array.isArray(this.meetingDetails.matches)) {
|
||||
@@ -6522,6 +6651,24 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ttr-preview {
|
||||
margin: 18px 0;
|
||||
padding: 16px;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 8px;
|
||||
background: #f8fbff;
|
||||
}
|
||||
|
||||
.ttr-preview-heading { display: flex; gap: 12px; justify-content: space-between; align-items: flex-start; }
|
||||
.ttr-preview-heading h3 { margin: 0; color: #1d4ed8; }
|
||||
.ttr-preview-heading p { margin: 4px 0 0; color: #475569; font-size: .9em; }
|
||||
.ttr-preview-badge { padding: 3px 8px; border-radius: 999px; color: #1e40af; background: #dbeafe; font-size: .8em; font-weight: 700; }
|
||||
.ttr-preview-table-wrap { overflow-x: auto; margin-top: 12px; }
|
||||
.ttr-preview-table { width: 100%; border-collapse: collapse; font-size: .9em; }
|
||||
.ttr-preview-table th, .ttr-preview-table td { padding: 8px; border-bottom: 1px solid #dbeafe; text-align: left; white-space: nowrap; }
|
||||
.ttr-preview-table input { width: 76px; padding: 5px; border: 1px solid #93c5fd; border-radius: 4px; }
|
||||
.ttr-preview-empty { margin: 10px 0 0; color: #64748b; }
|
||||
|
||||
.match-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
|
||||
Reference in New Issue
Block a user