Resolve external opponent TTR ratings and show lookup diagnostics
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m0s

This commit is contained in:
Torsten Schulz (local)
2026-10-28 11:03:46 +01:00
parent c473b64150
commit ecee9e0a00
6 changed files with 249 additions and 70 deletions

View File

@@ -10,21 +10,8 @@ import myTischtennisService from '../services/myTischtennisService.js';
import myTischtennisClient from '../clients/myTischtennisClient.js';
import { Op } from 'sequelize';
import sequelize from '../database.js';
import { resolveTtrPreview } from '../services/ttrPreviewService.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;
@@ -210,8 +197,7 @@ export const getMatchPlayers = async (req, res) => {
/**
* 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.
* is preferred. Missing opponents are resolved through myTischtennis club search.
*/
export const getTtrPreviewPlayers = async (req, res) => {
try {
@@ -224,6 +210,7 @@ export const getTtrPreviewPlayers = async (req, res) => {
await checkAccess(userToken, clubId);
const snapshotDate = new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date());
let cachedPlayers = [];
if (match) {
const [cachedRows] = await sequelize.query(
'SELECT payload FROM match_ttr_preview_cache WHERE match_id = :matchId AND snapshot_date = :snapshotDate LIMIT 1',
@@ -231,65 +218,26 @@ export const getTtrPreviewPlayers = async (req, res) => {
);
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 });
// Keep successful daily lookups, but retry missing players instead
// of caching an unsuccessful response until tomorrow.
cachedPlayers = Array.isArray(payload.players) ? payload.players : [];
}
}
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' };
}) };
const clubs = await Club.findAll();
const payload = await resolveTtrPreview({
homePlayers: Array.isArray(req.body?.homePlayers) ? req.body.homePlayers : [],
guestPlayers: Array.isArray(req.body?.guestPlayers) ? req.body.guestPlayers : [],
homeTeamName: req.body?.homeTeamName, guestTeamName: req.body?.guestTeamName,
homeClubName: req.body?.homeClubName, guestClubName: req.body?.guestClubName,
members, clubs, cachedPlayers,
}, { getSession: () => myTischtennisService.getSession(req.user.id), client: myTischtennisClient });
if (match) {
await sequelize.query(
`INSERT INTO match_ttr_preview_cache (match_id, snapshot_date, payload, created_at, updated_at)
VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())`,
VALUES (:matchId, :snapshotDate, :payload, NOW(), NOW())
ON DUPLICATE KEY UPDATE payload = VALUES(payload), updated_at = NOW()`,
{ replacements: { matchId: match.id, snapshotDate, payload: JSON.stringify(payload) } }
);
}