UNPKG

@lovebowls/leaguejs

Version:

A framework-agnostic JavaScript library for managing leagues, teams, and matches

365 lines (327 loc) 13.7 kB
/** * League model representing a bowls league */ import { Team } from './Team.js'; import { Match } from './Match.js'; import { validateLeague } from '../utils/validators.js'; import { generateGUID } from '../utils/shared.js'; import { initialiseFixtures as initialiseFixturesUtil, getMatchesRequiringAttention as getMatchesRequiringAttentionUtil, getConflictingMatchIds as getConflictingMatchIdsUtil } from '../utils/leagueUtils.js'; export class League { /** * Create a League instance from JSON data * @param {Object|string} jsonData - League data as JSON object or string * @returns {League} - New League instance */ static fromJSON(jsonData) { try { const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData; return new League(data); } catch (error) { throw new Error(`Failed to load league: ${error.message}`); } } /** * Create a new League * @param {Object} data - League data * @param {string} [data._id] - Unique identifier for the league (auto-generated if not provided) * @param {string} data.name - Name of the league * @param {Object} [data.settings] - League settings * @param {number} [data.settings.pointsForWin=3] - Points awarded for a win * @param {number} [data.settings.pointsForDraw=1] - Points awarded for a draw * @param {number} [data.settings.pointsForLoss=0] - Points awarded for a loss * @param {number} [data.settings.promotionPositions] - Number of teams that get promoted * @param {number} [data.settings.relegationPositions] - Number of teams that get relegated * @param {number} [data.settings.timesTeamsPlayOther=2] - Number of times teams play against each other (1-10) * @param {number} [data.settings.maxRinksPerSession] - Maximum number of rinks available per session * @param {Object} [data.settings.rinkPoints] - Settings for rink-based scoring * @param {number} [data.settings.rinkPoints.pointsPerRinkWin=2] - Points awarded per winning rink * @param {number} [data.settings.rinkPoints.pointsPerRinkDraw=1] - Points awarded per drawn rink * @param {number} [data.settings.rinkPoints.defaultRinks=4] - Default number of rinks per match * @param {boolean} [data.settings.rinkPoints.enabled=false] - Whether rink points are enabled * @param {Array<Team>} [data.teams=[]] - Initial list of teams * @param {Array<>} [data.matches=[]] - Initial list of matches * @param {Date} [data.createdAt] - Creation date (defaults to current date) * @param {Date} [data.updatedAt] - Last update date (defaults to current date) */ constructor(data) { const validationResult = validateLeague(data); if (!validationResult.isValid) { throw new Error(`Invalid league data: ${validationResult.errors.join(', ')}`); } this._id = data._id || generateGUID(); this.name = data.name; this.settings = { pointsForWin: data.settings?.pointsForWin || 3, pointsForDraw: data.settings?.pointsForDraw || 1, pointsForLoss: data.settings?.pointsForLoss || 0, promotionPositions: data.settings?.promotionPositions, relegationPositions: data.settings?.relegationPositions, timesTeamsPlayOther: data.settings?.timesTeamsPlayOther || 2, maxRinksPerSession: data.settings?.maxRinksPerSession }; if (data.settings?.rinkPoints) { this.settings.rinkPoints = { pointsPerRinkWin: data.settings.rinkPoints.pointsPerRinkWin || 2, pointsPerRinkDraw: data.settings.rinkPoints.pointsPerRinkDraw || 1, defaultRinks: data.settings.rinkPoints.defaultRinks || 4, enabled: data.settings.rinkPoints.enabled || false }; } this.teams = (data.teams || []).map(team => new Team(team)); this.matches = (data.matches || []).map(match => new Match(match)); this.createdAt = data.createdAt || new Date(); this.updatedAt = data.updatedAt || new Date(); } /** * Add a team to the league * @param {Team|Object} team - Team to add (either a Team instance or team data) * @returns {boolean} - True if team was added, false if already exists * @throws {Error} - If team data is invalid */ addTeam(team) { // If team is not already a Team instance, try to create one (which will validate the data) const teamInstance = team instanceof Team ? team : new Team(team); // Check if team with same ID already exists if (!this.teams.some(t => t._id === teamInstance._id)) { this.teams.push(teamInstance); this.updatedAt = new Date(); return true; } return false; } /** * Remove a team from the league * @param {string} teamId - ID of team to remove * @returns {boolean} - True if team was removed, false if not found */ removeTeam(teamId) { const initialLength = this.teams.length; this.teams = this.teams.filter(team => team._id !== teamId); if (this.teams.length !== initialLength) { this.updatedAt = new Date(); return true; } return false; } /** * Add a match to the league * @param {Match|Object} match - Match instance or match data * @returns {boolean} - True if match was added, false otherwise * @throws {Error} - If match data is invalid */ addMatch(match) { // If match is not already a Match instance, try to create one (which will validate the data) const matchInstance = match instanceof Match ? match : new Match(match); // Validate that both teams are in this league using IDs const homeTeamExists = this.teams.some(team => team._id === matchInstance.homeTeam._id); const awayTeamExists = this.teams.some(team => team._id === matchInstance.awayTeam._id); if (homeTeamExists && awayTeamExists) { // Check if a match with the same _id already exists if (this.matches.some(m => m._id === matchInstance._id)) { return false; // Match already exists } this.matches.push(matchInstance); this.updatedAt = new Date(); return true; } return false; } /** * Get a team by its ID * @param {string} teamId - ID of the team * @returns {Team|undefined} - The team if found, undefined otherwise */ getTeam(teamId) { return this.teams.find(team => team._id === teamId); } /** * Get a match by its ID * @param {string} matchId - The ID of the match * @returns {Match|undefined} - The match if found, undefined otherwise */ getMatch(matchId) { return this.matches.find(match => match._id === matchId); } /** * Get all matches for a team * @param {string} teamId - ID of the team * @returns {Array<Match>} - Array of matches involving the team */ getTeamMatches(teamId) { const team = this.getTeam(teamId); if (!team) { throw new Error(`Team not found with ID: ${teamId}`); } return this.matches.filter(match => match.homeTeam._id === teamId || match.awayTeam._id === teamId ); } /** * Get statistics for a team * @param {string} teamId - ID of the team * @returns {Object} - Team statistics */ getTeamStats(teamId) { const team = this.getTeam(teamId); if (!team) { throw new Error(`Team not found with ID: ${teamId}`); } const matches = this.getTeamMatches(teamId); const stats = { teamId: teamId, teamName: team.name, played: 0, won: 0, drawn: 0, lost: 0, shotsFor: 0, shotsAgainst: 0, points: 0, inPromotionPosition: false, inRelegationPosition: false }; // Sort matches by date to determine form/recent matches const playedMatches = matches .filter(match => match.result && match.date) // Ensure match has result and date .sort((a, b) => new Date(a.date) - new Date(b.date)); // Sort oldest to newest const recentMatchDetails = []; // Array to store details for tooltip playedMatches.forEach(match => { // Ensure result object and scores exist if (!match.result || typeof match.result.homeScore !== 'number' || typeof match.result.awayScore !== 'number') { console.warn(`Match ${match._id} for team ${team.name} missing valid result or scores.`); return; // Skip this match if data is incomplete } const isHome = match.homeTeam._id === teamId; const teamScore = isHome ? match.result.homeScore : match.result.awayScore; const opponentScore = isHome ? match.result.awayScore : match.result.homeScore; // const opponentName = isHome ? match.awayTeamName : match.homeTeamName; // Not needed for description format below stats.played++; stats.shotsFor += teamScore; stats.shotsAgainst += opponentScore; let resultChar = ''; if (teamScore > opponentScore) { stats.won++; stats.points += this.settings.pointsForWin; resultChar = 'W'; } else if (teamScore < opponentScore) { stats.lost++; stats.points += this.settings.pointsForLoss; resultChar = 'L'; } else { stats.drawn++; stats.points += this.settings.pointsForDraw; resultChar = 'D'; } // Format date for description - adjust locale/options as needed const matchDateStr = new Date(match.date).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); const description = `${matchDateStr}: ${match.homeTeam.name} ${match.result.homeScore} - ${match.result.awayScore} ${match.awayTeam.name}`; recentMatchDetails.push({ result: resultChar, description: description, date: match.date // Keep the original date object if needed elsewhere }); }); // Keep only the details for the last 5 matches stats.matches = recentMatchDetails.slice(-5); // Promotion/relegation positions are set in getLeagueTable after sorting return stats; } getLeagueTable() { // First, calculate basic stats for all teams const teamStats = this.teams.map(team => { const stats = this.getTeamStats(team._id); return { teamId: team._id, teamName: team.name, ...stats, shotDifference: stats.shotsFor - stats.shotsAgainst }; }); // Sort the table const sortedTable = teamStats.sort((a, b) => { // Sort by points first if (b.points !== a.points) { return b.points - a.points; } // Then by shot difference if (b.shotDifference !== a.shotDifference) { return b.shotDifference - a.shotDifference; } // Then by shots scored if (b.shotsFor !== a.shotsFor) { return b.shotsFor - a.shotsFor; } // Finally by team name return a.teamName.localeCompare(b.teamName); }); // Now update promotion/relegation positions if (this.settings.promotionPositions || this.settings.relegationPositions) { sortedTable.forEach((team, index) => { const position = index + 1; if (this.settings.promotionPositions && position <= this.settings.promotionPositions) { team.inPromotionPosition = true; } if (this.settings.relegationPositions && position > (sortedTable.length - this.settings.relegationPositions)) { team.inRelegationPosition = true; } }); } return { leagueData: sortedTable, metaData: { name: this.name } }; } /* * Initialise fixtures for the league using a round-robin algorithm. * Each team plays once per match day. Fixtures are scheduled according to the provided scheduling parameters. * Teams will play each other `this.settings.timesTeamsPlayOther` times, with home and away fixtures balanced * as per the cycles of the round-robin generation (e.g., first cycle A vs B, second cycle B vs A). * * @param {Date} [startDate] - The start date for the first round of matches. If null, matches will have null dates. * @param {Object} [schedulingParams] - Advanced scheduling parameters * @param {string} [schedulingParams.schedulingPattern='interval'] - 'interval' or 'dayOfWeek' * @param {number} [schedulingParams.intervalNumber=1] - Number of interval units between match days * @param {string} [schedulingParams.intervalUnit='weeks'] - 'days' or 'weeks' * @param {Array<number>} [schedulingParams.selectedDays] - Array of day numbers (0=Sunday, 1=Monday, etc.) for dayOfWeek pattern * @param {number} [schedulingParams.maxMatchesPerDay] - Maximum matches per day (defaults to maxRinksPerSession if not provided) * @returns {boolean} - True if fixtures were successfully created, false if there are fewer than 2 teams or if a match fails to be added. */ initialiseFixtures(startDate, schedulingParams = {}) { return initialiseFixturesUtil(this, startDate, schedulingParams); } /** * Returns the filtered list of matches requiring attention, sorted by priority. * @returns {Array} */ getMatchesRequiringAttention() { return getMatchesRequiringAttentionUtil(this); } /** * Returns a set of match IDs that are in scheduling conflict. * @returns {Set<string>} */ getConflictingMatchIds() { return getConflictingMatchIdsUtil(this); } /** * Convert league to JSON * @returns {Object} - JSON representation of the league */ toJSON() { return { _id: this._id, name: this.name, teams: this.teams.map(team => team.toJSON ? team.toJSON() : team), matches: this.matches.map(match => match.toJSON ? match.toJSON() : match), settings: this.settings, createdAt: this.createdAt, updatedAt: this.updatedAt }; } }