feat: implement TTR preview functionality with caching and UI integration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 58s

This commit is contained in:
Torsten Schulz (local)
2026-10-05 15:48:33 +02:00
parent 6d982add75
commit 546e3001d4
5 changed files with 287 additions and 2 deletions

View File

@@ -2,6 +2,29 @@ import MatchService from '../services/matchService.js';
import fs from 'fs';
import { emitScheduleMatchUpdated } from '../services/socketService.js';
import { devLog } from '../utils/logger.js';
import Match from '../models/Match.js';
import Member from '../models/Member.js';
import Club from '../models/Club.js';
import { checkAccess } from '../utils/userUtils.js';
import myTischtennisService from '../services/myTischtennisService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import { Op } from 'sequelize';
import sequelize from '../database.js';
const normalizeTtrName = (value) => String(value || '')
.normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
const playerName = (player) => [player?.firstname || player?.firstName, player?.lastname || player?.lastName]
.filter(Boolean).join(' ').trim();
const findClubForTeam = (clubs, teamName) => {
const teamKey = normalizeTtrName(teamName);
return clubs.find((club) => {
const clubKey = normalizeTtrName(club.name);
return clubKey && (teamKey === clubKey || teamKey.startsWith(`${clubKey} `) || clubKey.startsWith(`${teamKey} `));
}) || null;
};
export const uploadCSV = async (req, res) => {
try {
const clubId = req.body?.clubId ?? req.query?.clubId;
@@ -184,3 +207,90 @@ export const getMatchPlayers = async (req, res) => {
return res.status(500).json({ error: 'Failed to retrieve match players' });
}
};
/**
* Resolves the current TTR snapshots for a nuScore lineup. Local member data
* is preferred. Missing opponent data is fetched from the club ranking when
* the opposing club is also known in the database.
*/
export const getTtrPreviewPlayers = async (req, res) => {
try {
const { authcode: userToken } = req.headers;
const match = await Match.findByPk(req.params.matchId);
if (!match) return res.status(404).json({ error: 'Spiel nicht gefunden' });
await checkAccess(userToken, match.clubId);
// The product uses German match dates; do not roll the cache over at
// UTC midnight while a late-evening match is still in progress.
const snapshotDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
const [cachedRows] = await sequelize.query(
'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1',
{ replacements: { matchId: match.id, snapshotDate } }
);
if (cachedRows.length) {
const payload = typeof cachedRows[0].payload === 'string' ? JSON.parse(cachedRows[0].payload) : cachedRows[0].payload;
return res.json({ ...payload, cached: true, snapshotDate });
}
const sides = ['home', 'guest'];
const lineup = Object.fromEntries(sides.map((side) => [side, Array.isArray(req.body?.[`${side}Players`]) ? req.body[`${side}Players`] : []]));
const allPlayers = sides.flatMap((side) => lineup[side].map((player) => ({ side, player })));
const members = await Member.findAll({ where: { ttr: { [Op.ne]: null } } });
const byExternalId = new Map();
const byName = new Map();
members.forEach((member) => {
if (member.myTischtennisPlayerId) byExternalId.set(String(member.myTischtennisPlayerId), member);
const key = normalizeTtrName(`${member.firstName} ${member.lastName}`);
if (key && !byName.has(key)) byName.set(key, member);
else if (key) byName.set(key, null);
});
const resolved = new Map();
for (const { side, player } of allPlayers) {
const id = player?.nuLigaPersonId || player?.personId || player?.id;
const member = (id && byExternalId.get(String(id))) || byName.get(normalizeTtrName(playerName(player)));
if (member?.ttr != null) {
resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, {
ttr: Number(member.ttr), adjustmentConstant: Number(member.ttrAdjustmentConstant || 16), source: 'database'
});
}
}
const clubs = await Club.findAll({ where: { associationMemberNumber: { [Op.ne]: null }, myTischtennisFedNickname: { [Op.ne]: null } } });
let session = null;
for (const side of sides) {
const pending = lineup[side].filter((player) => !resolved.has(`${side}:${normalizeTtrName(playerName(player))}`));
if (!pending.length) continue;
const club = findClubForTeam(clubs, req.body?.[`${side}TeamName`]);
if (!club) continue;
try {
session ||= await myTischtennisService.getSession(req.user.id);
const rankings = await myTischtennisClient.getClubRankings(session.cookie, club.associationMemberNumber, club.myTischtennisFedNickname);
if (!rankings.success) continue;
const rankingByName = new Map(rankings.entries.map((entry) => [normalizeTtrName(`${entry.firstname} ${entry.lastname}`), entry]));
pending.forEach((player) => {
const entry = rankingByName.get(normalizeTtrName(playerName(player)));
if (Number.isFinite(Number(entry?.fedRank))) {
resolved.set(`${side}:${normalizeTtrName(playerName(player))}`, { ttr: Number(entry.fedRank), adjustmentConstant: 16, source: 'myTischtennis' });
}
});
} catch (_error) {
// Keep the player explicitly unresolved; the report remains usable.
}
}
const payload = { players: allPlayers.map(({ side, player }) => {
const value = resolved.get(`${side}:${normalizeTtrName(playerName(player))}`);
return { side, name: playerName(player), nuLigaPersonId: player?.nuLigaPersonId || null, ttr: value?.ttr ?? null, adjustmentConstant: value?.adjustmentConstant ?? null, source: value?.source || 'unavailable' };
}) };
await sequelize.query(
`INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at)
VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`,
{ replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } }
);
return res.json({ ...payload, cached: false, snapshotDate });
} catch (error) {
console.error('[getTtrPreviewPlayers]', error);
return res.status(error.statusCode || 500).json({ error: error.message || 'TTR-Werte konnten nicht geladen werden' });
}
};

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS match_ttr_preview_cache (
id INT AUTO_INCREMENT PRIMARY KEY,
match_id INT NOT NULL,
snapshot_date DATE NOT NULL,
payload JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_match_ttr_preview_day (match_id, snapshot_date),
CONSTRAINT fk_match_ttr_preview_match FOREIGN KEY (match_id) REFERENCES `match`(id) ON DELETE CASCADE
);

View File

@@ -1,5 +1,5 @@
import express from 'express';
import { uploadCSV, createManualMatch, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers } from '../controllers/matchController.js';
import { uploadCSV, createManualMatch, getLeaguesForCurrentSeason, getMatchesForLeagues, getMatchesForLeague, getLeagueTable, fetchLeagueTableFromMyTischtennis, updateMatchPlayers, getPlayerMatchStats, getMatchPlayers, getTtrPreviewPlayers } from '../controllers/matchController.js';
import { authenticate } from '../middleware/authMiddleware.js';
import { authorize } from '../middleware/authorizationMiddleware.js';
import multer from 'multer';
@@ -17,6 +17,7 @@ router.get('/leagues/:clubId/table/:leagueId', authenticate, authorize('schedule
router.post('/leagues/:clubId/table/:leagueId/fetch', authenticate, authorize('mytischtennis', 'write'), fetchLeagueTableFromMyTischtennis);
router.get('/:clubId/players', authenticate, authorize('schedule', 'read'), getMatchPlayers);
router.patch('/:matchId/players', authenticate, authorize('schedule', 'write'), updateMatchPlayers);
router.post('/:matchId/ttr-preview', authenticate, authorize('schedule', 'read'), getTtrPreviewPlayers);
router.get('/leagues/:clubId/stats/:leagueId', authenticate, authorize('schedule', 'read'), getPlayerMatchStats);

View File

@@ -1,6 +1,23 @@
import sequelize from '../database.js';
const migrations = [
{
id: '20261005_create_match_ttr_preview_cache',
async up() {
await sequelize.query(`
CREATE TABLE IF NOT EXISTS match_ttr_preview_cache (
id INT AUTO_INCREMENT PRIMARY KEY,
match_id INT NOT NULL,
snapshot_date DATE NOT NULL,
payload JSON NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uniq_match_ttr_preview_day (match_id, snapshot_date),
CONSTRAINT fk_match_ttr_preview_match FOREIGN KEY (match_id) REFERENCES \`match\`(id) ON DELETE CASCADE
)
`);
},
},
{
id: '20261005_add_hidden_test_user_flags',
async up() {

View File

@@ -178,6 +178,11 @@
<span v-if="isMatchCompleted" class="locked-indicator">🔒</span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'ttr', disabled: !canOpenTtrPreview }" :disabled="!canOpenTtrPreview" @click="openTtrSection" title="Wird nach Signatur der Gastaufstellung geladen">
<div class="section-icon">📈</div>
<span>TTR</span>
</button>
<button class="section-btn" :class="{ active: activeSection === 'completion', disabled: !canOpenCompletionStage }"
@click="setActiveSection('completion')" :disabled="!canOpenCompletionStage">
<div class="section-icon">✅</div>
@@ -492,7 +497,7 @@
<span class="score-value">{{ getOverallScore() }}</span>
</div>
</div>
<div class="result-table-wrap" tabindex="0" aria-label="Ergebnistabelle horizontal scrollen">
<table class="result-table">
<thead>
@@ -612,6 +617,20 @@
</div>
</div>
<div v-else-if="activeSection === 'ttr'" class="ttr-preview" aria-label="TTR-Vorschau">
<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>
<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>
<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>
</div>
<p v-if="ttrUnavailablePlayers.length" class="ttr-preview-empty">Noch ohne Wert: {{ ttrUnavailablePlayers.join(', ') }}.</p>
</div>
<div v-else-if="activeSection === 'completion'" class="completion-content">
<!-- Aufstellungen -->
<div class="lineups-summary">
@@ -840,6 +859,7 @@
import CryptoJS from 'crypto-js';
import apiClient, { backendBaseUrl } from '../apiClient';
import { onMatchReportSubmitted, offMatchReportSubmitted } from '../services/socketService.js';
import { calculateTtrChange } from '../services/ttrCalculator.mjs';
export default {
name: 'MatchReportDialog',
@@ -916,6 +936,12 @@ export default {
pinModalError: '',
// Ergebnisse für Ergebniserfassung
results: [],
// Kept separately from nuScore results: these are local event
// snapshots and must never alter the official match payload.
ttrSnapshots: {},
ttrPlayers: [],
ttrLoading: false,
ttrError: '',
// Fehlermeldungen für Satzeingaben
errors: [],
// Aktive Zelle der schwebenden Satz-Tastatur: { matchIndex, setIndex } oder null
@@ -1031,6 +1057,11 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
}
return this.areBothLineupsCertified();
},
canOpenTtrPreview() {
// Die Gastaufstellung ist der verlässliche Zeitpunkt, zu dem beide
// tatsächlichen Einzel-Aufstellungen feststehen.
return this.isGuestLineupCertified || this.viewOnly;
},
canOpenCompletionStage() {
if (this.viewOnly) return true;
if (this.isMatchCompleted) {
@@ -1096,6 +1127,34 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
return !this.areAllMatchResultsValid() || !this.areStartAndEndTimesValid();
},
ttrSingles() {
return this.results
.map((match, index) => ({ match, index }))
.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 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',
preview: { homeWin, homeLoss, guestWin, guestLoss },
actual: winner ? {
winner,
home: winner === 'home' ? homeWin : homeLoss,
guest: winner === 'guest' ? guestWin : guestLoss,
} : null,
};
}).filter(Boolean);
},
ttrUnavailablePlayers() {
return this.ttrPlayers.filter((player) => !Number.isFinite(Number(player.ttr))).map((player) => player.name);
},
draftStatusState() {
if (this.isDraftSyncing) {
return 'syncing';
@@ -3220,6 +3279,7 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
completed: false,
result: '1:0'
}));
this.ensureTtrSnapshots();
return;
}
@@ -3232,6 +3292,75 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
completed: false,
result: '1:0'
}));
this.ensureTtrSnapshots();
},
ensureTtrSnapshots() {
const next = {};
this.results.forEach((match, index) => {
if (this.isTtrDouble(match)) return;
if (this.ttrSnapshots[index]) next[index] = this.ttrSnapshots[index];
});
this.ttrSnapshots = next;
},
ttrSnapshot(index) {
return this.ttrSnapshots[index] || null;
},
normalizeTtrName(value) {
return String(value || '').normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/[^\p{L}\p{N}]+/gu, ' ').trim().toLocaleLowerCase('de-DE');
},
ttrLineupPlayers(side) {
const key = side === 'home' ? 'teamLineupHomePlayers' : 'teamLineupGuestPlayers';
return (this.meetingDetails?.[key] || []).filter((player) => player?.isSelected && player?.nuLigaPersonId > 0 && this.isSingleLineupPlayer(player));
},
async openTtrSection() {
if (!this.canOpenTtrPreview) return;
this.activeSection = 'ttr';
await this.loadTtrPreview();
},
async loadTtrPreview() {
if (!this.canOpenTtrPreview || !this.match?.id || !this.meetingDetails) return;
this.ttrLoading = true;
this.ttrError = '';
try {
const response = await apiClient.post(`/matches/${this.match.id}/ttr-preview`, {
homePlayers: this.ttrLineupPlayers('home'),
guestPlayers: this.ttrLineupPlayers('guest'),
homeTeamName: this.meetingData?.homeTeamname,
guestTeamName: this.meetingData?.guestTeamname,
});
this.ttrPlayers = Array.isArray(response.data?.players) ? response.data.players : [];
const index = new Map(this.ttrPlayers.map((player) => [`${player.side}:${this.normalizeTtrName(player.name)}`, player]));
const snapshots = {};
this.results.forEach((match, resultIndex) => {
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) };
});
this.ttrSnapshots = snapshots;
} catch (error) {
this.ttrError = error.response?.data?.error || 'TTR-Werte konnten nicht geladen werden.';
} finally {
this.ttrLoading = false;
}
},
isTtrDouble(match) {
const label = String(match?.label || '').toUpperCase();
return label.includes('D') || String(match?.homeName || '').includes(' / ') || String(match?.guestName || '').includes(' / ');
},
ttrWinner(match) {
if (!match?.completed) return null;
const homeWins = this.getPlayerSetWins(match, 'home');
const guestWins = this.getPlayerSetWins(match, 'guest');
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)) {
@@ -6522,6 +6651,24 @@ Wir wünschen den Spielen einen schönen, spannenden und fairen Verlauf und begr
text-align: center;
}
.ttr-preview {
margin: 18px 0;
padding: 16px;
border: 1px solid #bfdbfe;
border-radius: 8px;
background: #f8fbff;
}
.ttr-preview-heading { display: flex; gap: 12px; justify-content: space-between; align-items: flex-start; }
.ttr-preview-heading h3 { margin: 0; color: #1d4ed8; }
.ttr-preview-heading p { margin: 4px 0 0; color: #475569; font-size: .9em; }
.ttr-preview-badge { padding: 3px 8px; border-radius: 999px; color: #1e40af; background: #dbeafe; font-size: .8em; font-weight: 700; }
.ttr-preview-table-wrap { overflow-x: auto; margin-top: 12px; }
.ttr-preview-table { width: 100%; border-collapse: collapse; font-size: .9em; }
.ttr-preview-table th, .ttr-preview-table td { padding: 8px; border-bottom: 1px solid #dbeafe; text-align: left; white-space: nowrap; }
.ttr-preview-table input { width: 76px; padding: 5px; border: 1px solid #93c5fd; border-radius: 4px; }
.ttr-preview-empty { margin: 10px 0 0; color: #64748b; }
.match-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));