Show per-player TTR totals from completed singles only
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 57s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user