feat(Team): add isInLeagueTable and leagueTableRank fields with migration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m3s

This commit is contained in:
Torsten Schulz (local)
2026-09-22 12:03:03 +02:00
parent fc8c1a28c9
commit 2536143804
5 changed files with 117 additions and 12 deletions

View File

@@ -0,0 +1,5 @@
-- Persist the participant list and rank supplied by the official click-TT table.
-- Existing teams remain visible until their league table is refreshed once.
ALTER TABLE team
ADD COLUMN is_in_league_table TINYINT(1) NOT NULL DEFAULT 1,
ADD COLUMN league_table_rank INT NULL;

View File

@@ -101,6 +101,20 @@ const Team = sequelize.define('Team', {
allowNull: false,
defaultValue: 0,
},
// A schedule may contain provisional or stale opponents. Only teams that
// occur in the latest official click-TT table belong in the league table.
isInLeagueTable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: true,
},
// click-TT already applies all association-specific tie-breakers. Keep its
// rank instead of attempting to recreate those rules locally.
leagueTableRank: {
type: DataTypes.INTEGER,
allowNull: true,
defaultValue: null,
},
}, {
underscored: true,
tableName: 'team',

View File

@@ -1,6 +1,20 @@
import sequelize from '../database.js';
const migrations = [
{
id: '20260922_add_official_league_table_fields',
async up() {
const [visibilityColumns] = await sequelize.query("SHOW COLUMNS FROM `team` LIKE 'is_in_league_table'");
if (visibilityColumns.length === 0) {
await sequelize.query("ALTER TABLE `team` ADD COLUMN `is_in_league_table` TINYINT(1) NOT NULL DEFAULT 1");
}
const [rankColumns] = await sequelize.query("SHOW COLUMNS FROM `team` LIKE 'league_table_rank'");
if (rankColumns.length === 0) {
await sequelize.query("ALTER TABLE `team` ADD COLUMN `league_table_rank` INT NULL");
}
},
},
{
id: '20260819_add_manual_match_fields',
async up() {

View File

@@ -1175,16 +1175,46 @@ class AutoFetchMatchResultsService {
*/
async updateTeamsWithTableData(tableData, leagueId) {
let updatedCount = 0;
if (!tableData.length) {
devLog('Skipping league-table visibility update because no official rows were received');
return updatedCount;
}
// A schedule import can create provisional participants which are not part
// of the current official table. Hide them first; every official row below
// is explicitly enabled again. We retain the Team records because matches
// still reference them.
await Team.update(
{ isInLeagueTable: false, leagueTableRank: null },
{ where: { leagueId } }
);
const normalizeTeamName = (name) => String(name || '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/\s*\((?:j|m)\d+\)\s*/gi, ' ')
.replace(/\s+/g, ' ')
.trim();
const [teamsInLeague, league] = await Promise.all([
Team.findAll({ where: { leagueId } }),
League.findByPk(leagueId, { attributes: ['clubId', 'seasonId'] })
]);
for (const teamData of tableData) {
try {
// Find team by name in this league
const team = await Team.findOne({
where: {
leagueId: leagueId,
name: { [Op.like]: `%${teamData.teamName}%` }
}
});
const officialName = normalizeTeamName(teamData.teamName);
// Prefer an exact normalized name. The schedule frequently adds an
// age-group suffix such as "(J15)", while click-TT omits it.
let team = teamsInLeague.find((candidate) => normalizeTeamName(candidate.name) === officialName);
if (!team) {
const candidates = teamsInLeague.filter((candidate) => {
const candidateName = normalizeTeamName(candidate.name);
return candidateName.includes(officialName) || officialName.includes(candidateName);
});
if (candidates.length === 1) team = candidates[0];
}
if (team) {
await team.update({
@@ -1198,13 +1228,43 @@ class AutoFetchMatchResultsService {
pointsLost: teamData.pointsLost || 0,
tablePoints: (teamData.tablePointsWon || 0), // Legacy field (keep for compatibility)
tablePointsWon: teamData.tablePointsWon || 0,
tablePointsLost: teamData.tablePointsLost || 0
tablePointsLost: teamData.tablePointsLost || 0,
isInLeagueTable: true,
leagueTableRank: teamData.tableRank ?? null
});
updatedCount++;
devLog(` ✓ Updated team ${team.name} with table data`);
} else {
devLog(` ⚠ Team not found: ${teamData.teamName}`);
// The official table can contain a participant that is absent from a
// partial schedule import. Create it so the official table is still
// complete on the next response.
if (!league) {
devLog(` ⚠ Team not found and league is unavailable: ${teamData.teamName}`);
continue;
}
const createdTeam = await Team.create({
name: teamData.teamName,
clubId: league.clubId,
leagueId,
seasonId: league.seasonId,
matchesPlayed: teamData.matchesPlayed || 0,
matchesWon: teamData.matchesWon || 0,
matchesLost: teamData.matchesLost || 0,
matchesTied: teamData.matchesTied || 0,
setsWon: teamData.setsWon || 0,
setsLost: teamData.setsLost || 0,
pointsWon: teamData.pointsWon || 0,
pointsLost: teamData.pointsLost || 0,
tablePoints: teamData.tablePointsWon || 0,
tablePointsWon: teamData.tablePointsWon || 0,
tablePointsLost: teamData.tablePointsLost || 0,
isInLeagueTable: true,
leagueTableRank: teamData.tableRank ?? null
});
teamsInLeague.push(createdTeam);
updatedCount++;
devLog(` ✓ Created missing official table team ${createdTeam.name}`);
}
} catch (error) {
console.error(`Error updating team ${teamData.teamName}:`, error);

View File

@@ -646,13 +646,15 @@ class MatchService {
// Get all teams in this league
const teams = await Team.findAll({
where: {
leagueId: leagueId
leagueId: leagueId,
isInLeagueTable: true
},
attributes: [
'id', 'name', 'matchesPlayed', 'matchesWon', 'matchesLost', 'matchesTied',
'setsWon', 'setsLost', 'pointsWon', 'pointsLost', 'tablePoints', 'tablePointsWon', 'tablePointsLost'
'setsWon', 'setsLost', 'pointsWon', 'pointsLost', 'tablePoints', 'tablePointsWon', 'tablePointsLost',
'leagueTableRank'
],
order: [['id', 'ASC']]
order: [['leagueTableRank', 'ASC'], ['id', 'ASC']]
});
// Do not rely solely on the club name for identifying our teams.
@@ -681,6 +683,16 @@ class MatchService {
// number of points won: 0:0 (0) must therefore rank ahead of 0:2
// (-2), which in turn ranks ahead of 0:4 (-4).
teams.sort((left, right) => {
// The official rank is authoritative and includes the exact
// association tie-breakers.
if (left.leagueTableRank != null || right.leagueTableRank != null) {
if (left.leagueTableRank == null) return 1;
if (right.leagueTableRank == null) return -1;
if (left.leagueTableRank !== right.leagueTableRank) {
return left.leagueTableRank - right.leagueTableRank;
}
}
const leftBalance = left.tablePointsWon - left.tablePointsLost;
const rightBalance = right.tablePointsWon - right.tablePointsLost;
if (rightBalance !== leftBalance) return rightBalance - leftBalance;