Show per-player TTR totals from completed singles only
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s

This commit is contained in:
Torsten Schulz (local)
2026-10-28 11:27:03 +01:00
parent 68b09a2fa0
commit 9d8c316e97
2 changed files with 92 additions and 47 deletions

View File

@@ -617,26 +617,26 @@
</div>
</div>
<div v-else-if="activeSection === 'ttr'" class="ttr-preview" aria-label="TTR-Vorschau">
<div v-else-if="activeSection === 'ttr'" class="ttr-preview" aria-label="TTR nach Ergebnissen">
<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>
<div><h3>TTR nach Ergebnissen</h3><p>Berechneter TTR aus den abgeschlossenen Einzeln dieses Spielberichts. Ohne Einzelergebnis wird kein neuer Wert angezeigt. Doppel zählen nicht. Die Werte sind nicht offiziell.</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>
<table class="ttr-preview-table"><thead><tr><th>Mannschaft</th><th>Spieler</th><th>Ausgangs-TTR</th><th>Abgeschlossene Einzel</th><th>Berechneter TTR</th></tr></thead>
<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 v-for="entry in ttrPlayerResults" :key="entry.key">
<td>{{ entry.side === 'home' ? 'Heim' : 'Gast' }}</td>
<td>{{ entry.name }}</td>
<td>{{ entry.ttr ?? 'TTR fehlt' }}</td>
<td>{{ entry.completedCount }}</td>
<td v-if="entry.projectedTtr !== null">{{ entry.projectedTtr }}</td>
<td v-else-if="entry.completedCount && (entry.ttr === null || entry.missingOpponentTtr)">TTR-Wert fehlt</td>
<td v-else>—</td>
</tr>
<tr v-if="!ttrSingles.length"><td colspan="5">Noch keine Einzelpaarungen vorhanden. Bitte die Einzelaufstellungen prüfen.</td></tr>
<tr v-if="!ttrPlayerResults.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>
@@ -872,7 +872,7 @@
import CryptoJS from 'crypto-js';
import apiClient, { backendBaseUrl } from '../apiClient';
import { onMatchReportSubmitted, offMatchReportSubmitted } from '../services/socketService.js';
import { calculateTtrChange } from '../services/ttrCalculator.mjs';
import { calculateTtrEvent } from '../services/ttrCalculator.mjs';
import { resolveLineupToken } from '../services/lineupResolver.mjs';
export default {
@@ -1148,32 +1148,44 @@ 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);
const entry = {
return {
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 {
...entry,
preview: { homeWin, homeLoss, guestWin, guestLoss },
actual: winner ? {
winner,
home: winner === 'home' ? homeWin : homeLoss,
guest: winner === 'guest' ? guestWin : guestLoss,
} : null,
homeAdjustmentConstant: snapshot?.homeAdjustmentConstant ?? 16,
guestAdjustmentConstant: snapshot?.guestAdjustmentConstant ?? 16,
winner: this.ttrWinner(match),
};
});
},
ttrPlayerResults() {
const players = new Map();
for (const single of this.ttrSingles) {
for (const side of ['home', 'guest']) {
const name = single[`${side}Name`];
const key = `${side}:${this.normalizeTtrName(name)}`;
if (!players.has(key)) players.set(key, {
key, side, name, ttr: single[`${side}Ttr`],
adjustmentConstant: single[`${side}AdjustmentConstant`],
completedCount: 0, missingOpponentTtr: false, matches: [],
});
const player = players.get(key);
if (!single.winner) continue;
player.completedCount++;
const opponentTtr = single[side === 'home' ? 'guestTtr' : 'homeTtr'];
if (opponentTtr === null) player.missingOpponentTtr = true;
else player.matches.push({ opponentTtr, result: single.winner === side ? 'win' : 'loss' });
}
}
return [...players.values()].sort((a, b) => (a.side === b.side ? 0 : a.side === 'home' ? -1 : 1)).map(player => ({
...player,
projectedTtr: player.completedCount > 0 && player.ttr !== null && !player.missingOpponentTtr
? calculateTtrEvent({ ownTtr: player.ttr, adjustmentConstant: player.adjustmentConstant, matches: player.matches }).projectedTtr
: null,
}));
},
ttrUnavailablePlayers() {
return this.ttrPlayers.filter((player) => !this.isAvailableTtr(player.ttr)).map((player) => player.name);
},
@@ -3453,12 +3465,6 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
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)) {
return;

View File

@@ -2,14 +2,14 @@ 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';
import { calculateTtrEvent } 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: {
const context = vm.createContext({ console, calculateTtrEvent, apiClient: {
async post(url, body) {
requests.push({ url, body });
return { data: { players: [] } };
@@ -19,7 +19,7 @@ vm.runInContext(script, context);
const { methods, computed } = context.component;
function dialog() {
return Object.assign({}, methods, {
const report = 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 },
@@ -27,6 +27,8 @@ function dialog() {
ttrSnapshots: {},
ttrPlayers: [],
});
Object.defineProperty(report, 'ttrSingles', { get: () => computed.ttrSingles.call(report) });
return report;
}
test('singles with a D in their title are retained while double formation labels are excluded', () => {
@@ -48,22 +50,59 @@ test('missing TTR values retain the single and are not treated as zero', () => {
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(rows[0].winner, null);
assert.equal(computed.ttrUnavailablePlayers.call(report)[0], 'Yumin Kim');
});
test('available ratings produce the preview and a completed single uses the entered result', () => {
test('ratings without results show no projection; a completed single shows new ratings for both players', () => {
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);
assert.ok(computed.ttrPlayerResults.call(report).every(player => player.projectedTtr === null));
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);
const players = computed.ttrPlayerResults.call(report);
assert.equal(players[0].projectedTtr, 1308);
assert.equal(players[1].projectedTtr, 1292);
assert.equal(players[0].completedCount, 1);
});
test('player totals use the original snapshot and round once across completed singles only', () => {
const report = dialog();
report.results[0].completed = true; // A double must never enter the total.
report.results[1].completed = true;
report.results.push({ label: 'E1-E2', homeName: 'Anton Schottdorf', guestName: 'Timo Engler', completed: true });
report.results.push({ label: 'E1-E3', homeName: 'Anton Schottdorf', guestName: 'Ramón Hirschfeld', completed: false });
report.getPlayerSetWins = (_match, side) => side === 'home' ? 3 : 0;
report.applyTtrPreviewPlayers([
{ side: 'home', name: 'Anton Schottdorf', ttr: 1318 },
{ side: 'guest', name: 'Yumin Kim', ttr: 1382 },
{ side: 'guest', name: 'Timo Engler', ttr: 1429 },
{ side: 'guest', name: 'Ramón Hirschfeld', ttr: 1500 },
]);
let players = computed.ttrPlayerResults.call(report);
assert.equal(players[0].completedCount, 2);
assert.equal(players[0].projectedTtr, 1343);
assert.equal(players.find(player => player.name === 'Ramón Hirschfeld').projectedTtr, null);
report.results[1].completed = false;
report.results[2].completed = false;
players = computed.ttrPlayerResults.call(report);
assert.equal(players[0].completedCount, 0);
assert.equal(players[0].projectedTtr, null);
});
test('missing opponent ratings prevent misleading partial totals for completed results', () => {
const report = dialog();
report.results[1].completed = true;
report.getPlayerSetWins = (_match, side) => side === 'home' ? 3 : 0;
report.applyTtrPreviewPlayers([{ side: 'home', name: 'Anton Schottdorf', ttr: 1300 }]);
const players = computed.ttrPlayerResults.call(report);
assert.equal(players[0].completedCount, 1);
assert.equal(players[0].missingOpponentTtr, true);
assert.equal(players[0].projectedTtr, null);
assert.equal(players[1].projectedTtr, null);
});
test('standalone test mode calls the API and keeps real pairings without inventing ratings', async () => {
@@ -82,7 +121,7 @@ test('standalone test mode calls the API and keeps real pairings without inventi
const rows = computed.ttrSingles.call(report);
assert.equal(rows.length, 1);
assert.equal(rows[0].homeTtr, null);
assert.equal(rows[0].preview, null);
assert.equal(rows[0].winner, null);
});
test('TTR requests include displayed singles even when legacy lineup metadata is missing', () => {