feat: add TTR simulator and calculation engine, enhance member model with TTR adjustment constant
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 59s

This commit is contained in:
Torsten Schulz (local)
2026-10-05 15:18:10 +02:00
parent 6fa54163f6
commit 6d982add75
21 changed files with 466 additions and 27 deletions

View File

@@ -5,6 +5,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"test": "node --test tests/*.test.mjs",
"serve": "vite preview",
"i18n:audit": "node scripts/audit-i18n.js",
"i18n:status": "node scripts/translation-status.js",

View File

@@ -58,6 +58,7 @@ import { mapGetters, mapActions } from 'vuex';
import MatchReportDialog from './MatchReportDialog.vue';
import MatchReportApiDialog from './MatchReportApiDialog.vue';
import MatchReportHeaderActions from './MatchReportHeaderActions.vue';
import TtrSimulatorDialog from './TtrSimulatorDialog.vue';
const MyTischtennisAccount = defineAsyncComponent(() => import('../views/MyTischtennisAccount.vue'));
const ClickTtAccount = defineAsyncComponent(() => import('../views/ClickTtAccount.vue'));
@@ -78,6 +79,7 @@ export default {
MatchReportDialog,
MatchReportApiDialog,
MatchReportHeaderActions,
TtrSimulatorDialog,
MyTischtennisAccount,
ClickTtAccount,
PermissionsView,
@@ -130,6 +132,7 @@ export default {
'MatchReportDialog': MatchReportDialog,
'MatchReportApiDialog': MatchReportApiDialog,
'MatchReportHeaderActions': MatchReportHeaderActions,
'TtrSimulatorDialog': TtrSimulatorDialog,
'MyTischtennisAccount': MyTischtennisAccount,
'ClickTtAccount': ClickTtAccount,
'PermissionsView': PermissionsView,

View File

@@ -0,0 +1,155 @@
<template>
<div class="ttr-simulator">
<p class="intro">Berechnung mit dem TTR-Snapshot vor der Veranstaltung. Der angezeigte neue Wert ist voraussichtlich, nicht offiziell.</p>
<div class="form-grid">
<label v-if="clubId">
Spieler aus dem Verein
<select v-model="selectedMemberId" :disabled="membersLoading" @change="applySelectedMember">
<option value="">{{ membersLoading ? 'Spieler werden geladen …' : 'Manuelle Eingabe' }}</option>
<option v-for="member in membersWithTtr" :key="member.id" :value="String(member.id)">
{{ member.firstName }} {{ member.lastName }} · TTR {{ member.ttr }}
</option>
</select>
</label>
<label>
Eigener TTR-Snapshot
<input v-model.number="ownTtr" min="0" step="1" type="number" />
</label>
<label>
Änderungskonstante (A-Wert)
<input v-model.number="adjustmentConstant" min="0" step="0.1" type="number" />
</label>
</div>
<section>
<div class="section-heading">
<h4>Einzelbegegnungen</h4>
<button type="button" class="btn-secondary" @click="addMatch">Einzel hinzufügen</button>
</div>
<div v-for="(match, index) in matches" :key="match.id" class="match-row">
<input v-model.trim="match.name" aria-label="Name des Gegners" placeholder="Gegner" type="text" />
<input v-model.number="match.opponentTtr" aria-label="TTR des Gegners" min="0" placeholder="Gegner-TTR" step="1" type="number" />
<select v-model="match.result" aria-label="Ergebnis">
<option value="win">Sieg</option>
<option value="loss">Niederlage</option>
</select>
<span v-if="calculationFor(index)" class="single-change">
{{ formatPercent(calculationFor(index).expectedWinProbability) }} · {{ formatChange(calculationFor(index).displayChange) }}
</span>
<button type="button" class="remove" title="Einzel entfernen" @click="removeMatch(index)">×</button>
</div>
<p v-if="calculationError" class="error">{{ calculationError }}</p>
</section>
<section v-if="event" class="summary">
<span>Ausgangs-TTR: <strong>{{ event.ownTtrSnapshot }}</strong></span>
<span>Gesamtänderung: <strong :class="event.displayChange >= 0 ? 'positive' : 'negative'">{{ formatChange(event.displayChange) }}</strong></span>
<span>Voraussichtlicher TTR: <strong>{{ event.projectedTtr }}</strong></span>
</section>
</div>
</template>
<script>
import { calculateTtrEvent } from '../services/ttrCalculator.mjs';
import apiClient from '../apiClient.js';
let nextMatchId = 1;
export default {
name: 'TtrSimulatorDialog',
props: {
clubId: { type: [Number, String], default: null },
},
data() {
return {
ownTtr: 1300,
adjustmentConstant: 16,
selectedMemberId: '',
members: [],
membersLoading: false,
matches: [
{ id: nextMatchId++, name: '', opponentTtr: 1300, result: 'win' },
],
};
},
computed: {
membersWithTtr() {
return this.members.filter((member) => Number.isFinite(Number(member.ttr)));
},
calculationError() {
try {
calculateTtrEvent({ ownTtr: this.ownTtr, adjustmentConstant: this.adjustmentConstant, matches: this.matches });
return '';
} catch (error) {
return error.message;
}
},
event() {
try {
return calculateTtrEvent({ ownTtr: this.ownTtr, adjustmentConstant: this.adjustmentConstant, matches: this.matches });
} catch (_error) {
return null;
}
},
},
mounted() {
this.loadMembers();
},
methods: {
async loadMembers() {
if (!this.clubId) return;
this.membersLoading = true;
try {
const response = await apiClient.get(`/matches/${this.clubId}/players`);
this.members = Array.isArray(response.data) ? response.data : [];
} catch (_error) {
// The simulator remains fully usable with manual values if club data
// cannot be loaded (for example without the schedule permission).
this.members = [];
} finally {
this.membersLoading = false;
}
},
applySelectedMember() {
const member = this.members.find((entry) => String(entry.id) === this.selectedMemberId);
if (!member) return;
this.ownTtr = Number(member.ttr);
if (Number.isFinite(Number(member.ttrAdjustmentConstant))) {
this.adjustmentConstant = Number(member.ttrAdjustmentConstant);
}
},
addMatch() {
this.matches.push({ id: nextMatchId++, name: '', opponentTtr: this.ownTtr || 1300, result: 'win' });
},
removeMatch(index) {
if (this.matches.length > 1) this.matches.splice(index, 1);
},
calculationFor(index) {
return this.event?.calculations[index] || null;
},
formatPercent(value) {
return `Erwartung ${(value * 100).toFixed(1)} %`;
},
formatChange(value) {
return `${value > 0 ? '+' : ''}${value}`;
},
},
};
</script>
<style scoped>
.ttr-simulator { min-width: min(660px, 82vw); color: #243247; }
.intro { margin-top: 0; color: #526174; }
.form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin: 18px 0; }
label { display: grid; gap: 5px; font-weight: 650; font-size: .9rem; }
input, select { min-height: 36px; border: 1px solid #cbd5e1; border-radius: 6px; padding: 6px 8px; font: inherit; }
.section-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.section-heading h4 { margin: 0; }
.match-row { display: grid; grid-template-columns: minmax(120px, 1.2fr) minmax(100px, .7fr) 120px minmax(150px, 1fr) 32px; align-items: center; gap: 8px; margin: 8px 0; }
.single-change { font-variant-numeric: tabular-nums; white-space: nowrap; }
.remove { border: 0; background: transparent; color: #b42318; font-size: 1.4rem; cursor: pointer; }
.summary { display: flex; gap: 20px; flex-wrap: wrap; margin-top: 22px; padding: 14px; border-radius: 8px; background: #edf7f1; font-variant-numeric: tabular-nums; }
.positive { color: #167045; }.negative, .error { color: #b42318; }
@media (max-width: 620px) { .ttr-simulator { min-width: 0; } .form-grid, .match-row { grid-template-columns: 1fr; } }
</style>

View File

@@ -38,6 +38,14 @@
>
🧪 Spielbericht simulieren
</button>
<button
v-if="showScheduleActions"
type="button"
class="btn-secondary schedule-add-match-button"
@click="$emit('open-ttr-simulator')"
>
TTR-Simulator
</button>
<button v-if="showFriendlyActions" @click="$emit('open-friendly-match-modal')" class="btn-secondary">Freundschaftsspiel</button>
<button
v-if="showGalleryButton"
@@ -232,6 +240,8 @@ export default {
'season-change',
'open-import-modal',
'open-manual-match-modal',
'open-match-report-simulator',
'open-ttr-simulator',
'open-friendly-match-modal',
'open-gallery-dialog',
'update:team-search-query',

View File

@@ -0,0 +1,80 @@
/**
* Reusable calculation engine for the German TTR rating.
*
* A rating event (for example a team match) always uses the rating snapshot
* from before the event. Therefore this module never mutates a player's TTR;
* callers pass the same snapshot into every single calculation and sum the
* raw changes with calculateTtrEvent().
*/
export const TTR_RATING_DIFFERENCE_DIVISOR = 150;
function finiteNumber(value, name) {
const number = Number(value);
if (!Number.isFinite(number)) throw new TypeError(`${name} muss eine endliche Zahl sein.`);
return number;
}
export function roundTtr(value) {
const number = finiteNumber(value, 'TTR-Änderung');
// "Rundung auf ganze Zahlen" must be symmetric for positive and negative
// values. Math.round(-0.5) would incorrectly return -0 in JavaScript.
return number < 0 ? -Math.round(Math.abs(number)) : Math.round(number);
}
export function calculateExpectedWinProbability(ownTtr, opponentTtr) {
const own = finiteNumber(ownTtr, 'Eigener TTR');
const opponent = finiteNumber(opponentTtr, 'Gegner-TTR');
return 1 / (1 + 10 ** ((opponent - own) / TTR_RATING_DIFFERENCE_DIVISOR));
}
/** Calculate the TTR effect of one single. result accepts win/loss or 1/0. */
export function calculateTtrChange(ownTtr, opponentTtr, result, adjustmentConstant) {
const expectedWinProbability = calculateExpectedWinProbability(ownTtr, opponentTtr);
const adjustment = finiteNumber(adjustmentConstant, 'Änderungskonstante');
if (adjustment < 0) throw new RangeError('Die Änderungskonstante darf nicht negativ sein.');
const normalizedResult = result === 'win' || result === true || Number(result) === 1
? 1
: result === 'loss' || result === false || Number(result) === 0
? 0
: null;
if (normalizedResult === null) throw new TypeError('Ergebnis muss Sieg oder Niederlage sein.');
const rawChange = (normalizedResult - expectedWinProbability) * adjustment;
return {
result: normalizedResult,
expectedWinProbability,
rawChange,
displayChange: roundTtr(rawChange),
};
}
/**
* Calculate an entire event from immutable TTR snapshots.
* Each entry needs opponentTtr and result and may override adjustmentConstant.
*/
export function calculateTtrEvent({ ownTtr, adjustmentConstant, matches }) {
if (!Array.isArray(matches)) throw new TypeError('matches muss ein Array sein.');
const ownSnapshot = finiteNumber(ownTtr, 'Eigener TTR');
const defaultAdjustment = finiteNumber(adjustmentConstant, 'Änderungskonstante');
const calculations = matches.map((match) => ({
...match,
ownTtrSnapshot: ownSnapshot,
...calculateTtrChange(
ownSnapshot,
match.opponentTtr,
match.result,
match.adjustmentConstant ?? defaultAdjustment,
),
}));
const rawChange = calculations.reduce((total, match) => total + match.rawChange, 0);
const displayChange = roundTtr(rawChange);
return {
ownTtrSnapshot: ownSnapshot,
calculations,
rawChange,
displayChange,
projectedTtr: ownSnapshot + displayChange,
};
}

View File

@@ -32,6 +32,7 @@
@open-import-modal="openImportModal"
@open-manual-match-modal="openManualMatchDialog"
@open-match-report-simulator="openMatchReportSimulator"
@open-ttr-simulator="openTtrSimulator"
@open-friendly-match-modal="openFriendlyMatchDialog"
@open-gallery-dialog="openGalleryDialog"
@update:team-search-query="teamSearchQuery = $event"
@@ -2953,6 +2954,14 @@ export default {
}
});
},
openTtrSimulator() {
this.openDialog({
title: 'TTR-Simulator',
component: 'TtrSimulatorDialog',
props: { clubId: this.currentClub }
});
},
async loadLeagueTable(leagueId) {
try {

View File

@@ -0,0 +1,44 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
calculateExpectedWinProbability,
calculateTtrChange,
calculateTtrEvent,
roundTtr,
} from '../src/services/ttrCalculator.mjs';
test('equal ratings have a 50 percent expected win probability', () => {
assert.equal(calculateExpectedWinProbability(1500, 1500), 0.5);
assert.equal(calculateTtrChange(1500, 1500, 'win', 16).displayChange, 8);
assert.equal(calculateTtrChange(1500, 1500, 'loss', 16).displayChange, -8);
});
test('a win against a stronger player gains more than a win against a weaker player', () => {
const strong = calculateTtrChange(1318, 1429, 'win', 16);
const weak = calculateTtrChange(1318, 1180, 'win', 16);
assert.ok(strong.rawChange > weak.rawChange);
assert.ok(calculateTtrChange(1318, 1429, 'loss', 16).rawChange > calculateTtrChange(1318, 1180, 'loss', 16).rawChange);
});
test('an event uses one immutable snapshot and rounds only the total', () => {
const event = calculateTtrEvent({
ownTtr: 1318,
adjustmentConstant: 16,
matches: [
{ opponentTtr: 1382, result: 'win' },
{ opponentTtr: 1429, result: 'win' },
],
});
assert.equal(event.calculations[0].ownTtrSnapshot, 1318);
assert.equal(event.ownTtrSnapshot, 1318);
assert.equal(event.displayChange, 25);
assert.equal(event.projectedTtr, 1343);
assert.equal(event.displayChange, roundTtr(event.calculations[0].rawChange + event.calculations[1].rawChange));
assert.equal(event.projectedTtr, 1318 + event.displayChange);
});
test('the adjustment constant is player-specific input', () => {
const with16 = calculateTtrChange(1500, 1500, 'win', 16).rawChange;
const with8 = calculateTtrChange(1500, 1500, 'win', 8).rawChange;
assert.equal(with16, with8 * 2);
});