From 2536143804bffeeee291ccb9c66a430a8eb27374 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Tue, 22 Sep 2026 12:03:03 +0200 Subject: [PATCH] feat(Team): add isInLeagueTable and leagueTableRank fields with migration --- ...60922_add_official_league_table_fields.sql | 5 ++ backend/models/Team.js | 14 ++++ backend/scripts/runMigrations.js | 14 ++++ .../services/autoFetchMatchResultsService.js | 78 ++++++++++++++++--- backend/services/matchService.js | 18 ++++- 5 files changed, 117 insertions(+), 12 deletions(-) create mode 100644 backend/migrations/20260922_add_official_league_table_fields.sql diff --git a/backend/migrations/20260922_add_official_league_table_fields.sql b/backend/migrations/20260922_add_official_league_table_fields.sql new file mode 100644 index 00000000..dbb5808a --- /dev/null +++ b/backend/migrations/20260922_add_official_league_table_fields.sql @@ -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; diff --git a/backend/models/Team.js b/backend/models/Team.js index cfd11265..882c9134 100755 --- a/backend/models/Team.js +++ b/backend/models/Team.js @@ -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', diff --git a/backend/scripts/runMigrations.js b/backend/scripts/runMigrations.js index 6b332ce1..de0c389a 100644 --- a/backend/scripts/runMigrations.js +++ b/backend/scripts/runMigrations.js @@ -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() { diff --git a/backend/services/autoFetchMatchResultsService.js b/backend/services/autoFetchMatchResultsService.js index 3e2a3bac..b7dfdc2f 100755 --- a/backend/services/autoFetchMatchResultsService.js +++ b/backend/services/autoFetchMatchResultsService.js @@ -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); diff --git a/backend/services/matchService.js b/backend/services/matchService.js index 442c6f2a..4ca60b10 100755 --- a/backend/services/matchService.js +++ b/backend/services/matchService.js @@ -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;