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
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 59s
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
155
frontend/src/components/TtrSimulatorDialog.vue
Normal file
155
frontend/src/components/TtrSimulatorDialog.vue
Normal 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>
|
||||
@@ -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',
|
||||
|
||||
80
frontend/src/services/ttrCalculator.mjs
Normal file
80
frontend/src/services/ttrCalculator.mjs
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
44
frontend/tests/ttrCalculator.test.mjs
Normal file
44
frontend/tests/ttrCalculator.test.mjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user