Fix empty TTR preview and retain singles with missing ratings
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s

This commit is contained in:
Torsten Schulz (local)
2026-10-28 10:40:27 +01:00
parent 9f349ed7b5
commit c473b64150
2 changed files with 128 additions and 26 deletions

View File

@@ -626,7 +626,18 @@
<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>
<tbody>
<tr v-for="entry in ttrSingles" :key="`ttr-${entry.index}`">
<td>{{ entry.index + 1 }}</td>
<td>{{ entry.homeName }} · {{ entry.homeTtr ?? 'TTR fehlt' }}</td>
<td>{{ entry.guestName }} · {{ entry.guestTtr ?? 'TTR fehlt' }}</td>
<td>{{ entry.preview ? 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-if="entry.preview">Heimsieg: {{ formatTtrChange(entry.preview.homeWin.displayChange) }} · Heimniederlage: {{ formatTtrChange(entry.preview.homeLoss.displayChange) }}</td>
<td v-else>Berechnung erst mit beiden TTR-Werten möglich.</td>
</tr>
<tr v-if="!ttrSingles.length"><td colspan="5">Noch keine Einzelpaarungen vorhanden. Bitte die Einzelaufstellungen prüfen.</td></tr>
</tbody></table>
</div>
<p v-if="ttrUnavailablePlayers.length" class="ttr-preview-empty">Noch ohne Wert: {{ ttrUnavailablePlayers.join(', ') }}.</p>
</div>
@@ -1134,16 +1145,23 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
.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 entry = {
index,
homeName: match.homeName || 'Heim',
guestName: match.guestName || 'Gast',
homeTtr: this.isAvailableTtr(snapshot?.homeTtr) ? Number(snapshot.homeTtr) : null,
guestTtr: this.isAvailableTtr(snapshot?.guestTtr) ? Number(snapshot.guestTtr) : null,
preview: null,
actual: null,
};
if (entry.homeTtr === null || entry.guestTtr === null) return entry;
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',
...entry,
preview: { homeWin, homeLoss, guestWin, guestLoss },
actual: winner ? {
winner,
@@ -1151,10 +1169,10 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
guest: winner === 'guest' ? guestWin : guestLoss,
} : null,
};
}).filter(Boolean);
});
},
ttrUnavailablePlayers() {
return this.ttrPlayers.filter((player) => !Number.isFinite(Number(player.ttr))).map((player) => player.name);
return this.ttrPlayers.filter((player) => !this.isAvailableTtr(player.ttr)).map((player) => player.name);
},
draftStatusState() {
if (this.isDraftSyncing) {
@@ -3291,6 +3309,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
: [];
if (serverMatches.length > 0) {
this.results = serverMatches.map((match, index) => ({
matchType: match.matchType,
label: (typeof match?.name === 'string' && match.name.trim()) || this.getMatchFormationLabel(match, index) || `Spiel ${index + 1}`,
homeName: this.resolveMatchSide(match, index, 'home'),
guestName: this.resolveMatchSide(match, index, 'guest'),
@@ -3324,6 +3343,9 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
ttrSnapshot(index) {
return this.ttrSnapshots[index] || null;
},
isAvailableTtr(value) {
return value !== null && value !== undefined && String(value).trim() !== '' && Number.isFinite(Number(value));
},
normalizeTtrName(value) {
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
},
@@ -3349,21 +3371,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
homeTeamName: this.meetingData?.homeTeamname,
guestTeamName: this.meetingData?.guestTeamname,
});
const resolvedByKey = new Map((response.data?.players || []).map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player]));
const players = ['home', 'guest'].flatMap((side) => this.ttrLineupPlayers(side).map((player, index) => ({
side,
name: [player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '),
nuLigaPersonId: player.nuLigaPersonId || null,
...resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`),
// Test-Aufstellungen sind Platzhalternamen. Nur nicht
// über die API auflösbare Spieler erhalten Testwerte.
ttr: Number.isFinite(Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr))
? Number(resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.ttr)
: (side === 'home' ? 1320 - index * 23 : 1345 - index * 19),
adjustmentConstant: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.adjustmentConstant || 16,
source: resolvedByKey.get(`${side}:${this.normalizeTtrName([player.firstname || player.firstName, player.lastname || player.lastName].filter(Boolean).join(' '))}`)?.source || 'test',
})));
this.applyTtrPreviewPlayers(players);
this.applyTtrPreviewPlayers(Array.isArray(response.data?.players) ? response.data.players : []);
return;
}
if (!this.match?.id) {
@@ -3391,14 +3399,22 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
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) };
snapshots[resultIndex] = {
homeTtr: this.isAvailableTtr(home?.ttr) ? Number(home.ttr) : null,
guestTtr: this.isAvailableTtr(guest?.ttr) ? Number(guest.ttr) : null,
homeAdjustmentConstant: Number(home?.adjustmentConstant || 16),
guestAdjustmentConstant: Number(guest?.adjustmentConstant || 16),
};
});
this.ttrSnapshots = snapshots;
},
isTtrDouble(match) {
const label = String(match?.label || '').toUpperCase();
return label.includes('D') || String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
const type = String(match?.matchType || '').toUpperCase();
if (type === 'DOUBLE' || type === 'DOUBLES') return true;
if (type === 'SINGLE' || type === 'SINGLES') return false;
const label = String(match?.label || '');
return /\b(?:DOPPEL|DOUBLES?)\b|(?:^|[\s–-])D[AB]?\d+(?=$|[\s–-])/i.test(label) ||
String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
},
ttrWinner(match) {
if (!match?.completed) return null;

View File

@@ -0,0 +1,86 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
import { calculateTtrChange } from '../src/services/ttrCalculator.mjs';
const source = readFileSync(new URL('../src/components/MatchReportApiDialog.vue', import.meta.url), 'utf8');
const script = source.match(/<script>([\s\S]*?)<\/script>/)[1]
.replace(/^import .*;\n/gm, '')
.replace('export default', 'globalThis.component =');
const requests = [];
const context = vm.createContext({ console, calculateTtrChange, apiClient: {
async post(url, body) {
requests.push({ url, body });
return { data: { players: [] } };
},
} });
vm.runInContext(script, context);
const { methods, computed } = context.component;
function dialog() {
return Object.assign({}, methods, {
results: [
{ label: 'D1-D1', homeName: 'Anton Schottdorf / Anna Wolf', guestName: 'Yumin Kim / Timo Engler' },
{ label: '1. Einzel der Runde', homeName: 'Anton Schottdorf', guestName: 'Yumin Kim', completed: false },
],
ttrSnapshots: {},
ttrPlayers: [],
});
}
test('singles with a D in their title are retained while double formation labels are excluded', () => {
const report = dialog();
assert.equal(report.isTtrDouble(report.results[0]), true);
assert.equal(report.isTtrDouble(report.results[1]), false);
assert.equal(report.isTtrDouble({ label: 'DA2 – DB2' }), true);
assert.equal(report.isTtrDouble({ label: 'Doppel 1' }), true);
assert.equal(report.isTtrDouble({ label: '3. Runde', matchType: 'SINGLE' }), false);
});
test('missing TTR values retain the single and are not treated as zero', () => {
const report = dialog();
report.applyTtrPreviewPlayers([
{ side: 'home', name: 'Anton Schottdorf', ttr: 1300 },
{ side: 'guest', name: 'Yumin Kim', ttr: null },
]);
const rows = computed.ttrSingles.call(report);
assert.equal(rows.length, 1);
assert.equal(rows[0].homeTtr, 1300);
assert.equal(rows[0].guestTtr, null);
assert.equal(rows[0].preview, null);
assert.equal(computed.ttrUnavailablePlayers.call(report)[0], 'Yumin Kim');
});
test('available ratings produce the preview and a completed single uses the entered result', () => {
const report = dialog();
report.applyTtrPreviewPlayers([
{ side: 'home', name: 'Anton Schottdorf', ttr: 1300 },
{ side: 'guest', name: 'Yumin Kim', ttr: 1300 },
]);
assert.equal(computed.ttrSingles.call(report)[0].preview.homeWin.displayChange, 8);
report.results[1].completed = true;
report.getPlayerSetWins = (_match, side) => side === 'home' ? 3 : 0;
const row = computed.ttrSingles.call(report)[0];
assert.equal(row.actual.home.displayChange, 8);
assert.equal(row.actual.guest.displayChange, -8);
});
test('standalone test mode calls the API and keeps real pairings without inventing ratings', async () => {
const report = dialog();
Object.assign(report, {
standaloneTest: true,
canOpenTtrPreview: true,
meetingDetails: {},
meetingData: {},
$store: { getters: { currentClub: 1 } },
ttrLineupPlayers: () => [],
});
await report.loadTtrPreview();
assert.equal(requests.at(-1).url, '/matches/ttr-preview/standalone');
assert.equal(report.ttrError, '');
const rows = computed.ttrSingles.call(report);
assert.equal(rows.length, 1);
assert.equal(rows[0].homeTtr, null);
assert.equal(rows[0].preview, null);
});