feat(Team): add isInLeagueTable and leagueTableRank fields with migration
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m3s
All checks were successful
Deploy tt-tagebuch / deploy (push) Successful in 1m3s
This commit is contained in:
@@ -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;
|
||||
@@ -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',
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user