@lovebowls/leaguejs
Version:
A framework-agnostic JavaScript library for managing leagues, teams, and matches
365 lines (327 loc) • 13.7 kB
JavaScript
/**
* 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
};
}
}