@lovebowls/leaguejs
Version:
A framework-agnostic JavaScript library for managing leagues, teams, and matches
1,078 lines (1,001 loc) • 41.4 kB
JavaScript
'use strict';
/**
* Validation utilities for the leagueJS
*/
function validateLeague(data) {
const errors = [];
if (!data.name) {
errors.push('League name is required');
}
if (data.settings) {
if (typeof data.settings.pointsForWin !== 'undefined') {
if (typeof data.settings.pointsForWin !== 'number' || data.settings.pointsForWin < 0) {
errors.push('Invalid points settings');
}
}
if (typeof data.settings.pointsForDraw !== 'undefined') {
if (typeof data.settings.pointsForDraw !== 'number' || data.settings.pointsForDraw < 0) {
errors.push('Invalid points settings');
}
}
if (typeof data.settings.pointsForLoss !== 'undefined') {
if (typeof data.settings.pointsForLoss !== 'number' || data.settings.pointsForLoss < 0) {
errors.push('Invalid points settings');
}
}
// Validate timesTeamsPlayOther
if (typeof data.settings.timesTeamsPlayOther !== 'undefined') {
if (typeof data.settings.timesTeamsPlayOther !== 'number' || data.settings.timesTeamsPlayOther < 1 || data.settings.timesTeamsPlayOther > 10) {
errors.push('timesTeamsPlayOther must be an integer between 1 and 10');
}
}
}
return {
isValid: errors.length === 0,
errors
};
}
function validateTeam(data) {
const errors = [];
if (!data._id || !data._id.trim()) {
errors.push('Team ID is required');
}
return {
isValid: errors.length === 0,
errors
};
}
function validateMatch(data) {
const errors = [];
if (!data.homeTeam || !data.homeTeam._id) {
errors.push('Home team is required');
}
if (!data.awayTeam || !data.awayTeam._id) {
errors.push('Away team is required');
}
if (data.date && !(data.date instanceof Date) && isNaN(new Date(data.date).getTime())) {
errors.push('Invalid date format');
}
// Validate result scores if result object exists
if (data.result) {
if (typeof data.result.homeScore !== 'number' || data.result.homeScore < 0) {
errors.push('Home score must be a non-negative number');
}
if (typeof data.result.awayScore !== 'number' || data.result.awayScore < 0) {
errors.push('Away score must be a non-negative number');
}
// Optionally, could add validation for rinkScores structure here if needed
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Generates a GUID (Globally Unique Identifier)
* @returns {string} A GUID string in the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
*/
function generateGUID() {
// Generate random hex digits
const hex = () => Math.floor(Math.random() * 16).toString(16);
// Build GUID in format: 8-4-4-4-12
return [
// 8 hex digits
Array(8).fill(0).map(hex).join(''),
// 4 hex digits
Array(4).fill(0).map(hex).join(''),
// 4 hex digits
Array(4).fill(0).map(hex).join(''),
// 4 hex digits
Array(4).fill(0).map(hex).join(''),
// 12 hex digits
Array(12).fill(0).map(hex).join('')].join('-');
}
/**
* Team model representing a bowls team
*/
class Team {
/**
* Create a new Team
* @param {Object} data - Team data
* @param {string} data._id - Unique identifier for the team
* @param {string} [data.name] - Name of the team (defaults to _id if not provided)
* @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 = validateTeam(data);
if (!validationResult.isValid) {
throw new Error(validationResult.errors[0]);
}
this._id = data._id || generateGUID();
this.name = data.name || data._id;
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
}
/**
* Update team details
* @param {Object} updates - Updated team details
*/
update(updates) {
Object.assign(this.details, updates);
this.updatedAt = new Date();
}
/**
* Convert team to JSON
* @returns {Object} - JSON representation of the team
*/
toJSON() {
return {
_id: this._id,
name: this.name,
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
}
/**
* Match model representing a bowls match between two teams
* @typedef {Object} MatchData
* @property {Object} homeTeam - Home team object with _id property
* @property {Object} awayTeam - Away team object with _id property
* @property {string} [_id] - Unique identifier for the match
* @property {Date|string|null} [date] - Match date (can be null for unscheduled matches)
* @property {number} [rink] - Assigned rink number for the match
* @property {Object} [result] - Optional match result data containing scores
* @property {number} [result.homeScore] - Home team's score
* @property {number} [result.awayScore] - Away team's score
* @property {Array<Object>} [result.rinkScores] - Optional individual rink scores
* @property {Date} [createdAt] - Creation date (defaults to current date)
* @property {Date} [updatedAt] - Last update date (defaults to current date)
*/
class Match {
/**
* Create a new Match
* @param {Object} data - Match data
* @param {Object} data.homeTeam - Home team object with _id property
* @param {Object} data.awayTeam - Away team object with _id property
* @param {Date|string|null} [data.date] - Match date (can be null for unscheduled matches)
* @param {number} [data.rink] - Assigned rink number for the match
* @param {Object} [data.result] - Optional match result data containing scores
* @param {number} [data.result.homeScore] - Home team's score
* @param {number} [data.result.awayScore] - Away team's score
* @param {Array<Object>} [data.result.rinkScores] - Optional individual rink scores
* @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 = validateMatch(data);
if (!validationResult.isValid) {
throw new Error(validationResult.errors[0]);
}
if (data.homeTeam._id === data.awayTeam._id) {
throw new Error('Home and away teams must be different');
}
this._id = data._id || generateGUID();
this.homeTeam = data.homeTeam;
this.awayTeam = data.awayTeam;
this.date = data.date ? new Date(data.date) : null;
this.rink = data.rink || null;
this.createdAt = data.createdAt || new Date();
this.updatedAt = data.updatedAt || new Date();
// Process result if scores are provided
if (data.result && typeof data.result.homeScore === 'number' && typeof data.result.awayScore === 'number') {
this.result = {
homeScore: data.result.homeScore,
awayScore: data.result.awayScore,
rinkScores: data.result.rinkScores || null
};
} else {
this.result = null; // Ensure result is null if scores are not provided
}
}
/**
* Get the home team name
* @returns {string} - The name of the home team
*/
get homeTeamName() {
return this.homeTeam.name;
}
/**
* Get the away team name
* @returns {string} - The name of the away team
*/
get awayTeamName() {
return this.awayTeam.name;
}
/**
* Determines the winner of the match based on scores.
* @returns {string|null} - The name of the winning team, 'draw', or null if no result is set.
*/
getWinner() {
if (!this.result) {
return null;
}
if (this.result.homeScore > this.result.awayScore) {
return this.homeTeamName;
}
if (this.result.awayScore > this.result.homeScore) {
return this.awayTeamName;
}
return 'draw';
}
/**
* Checks if the match resulted in a draw.
* @returns {boolean|null} - True if it's a draw, false otherwise, or null if no result is set.
*/
isDraw() {
if (!this.result) {
return null;
}
return this.result.homeScore === this.result.awayScore;
}
/**
* Set rink scores for an existing match result
* @param {Array} rinkScores - Array of rink scores [{homeScore, awayScore}, ...]
* @returns {boolean} - True if scores were set, false if no result exists
*/
setRinkScores(rinkScores) {
if (!this.result) return false;
this.result.rinkScores = rinkScores;
this.updatedAt = new Date();
return true;
}
/**
* Get rink win/draw counts
* @returns {Object|null} - Object with rink win counts or null if no rink scores
*/
getRinkResults() {
if (!this.result || !this.result.rinkScores) return null;
const rinkResults = {
homeWins: 0,
awayWins: 0,
draws: 0,
total: this.result.rinkScores.length
};
this.result.rinkScores.forEach(rink => {
if (rink.homeScore > rink.awayScore) {
rinkResults.homeWins++;
} else if (rink.awayScore > rink.homeScore) {
rinkResults.awayWins++;
} else {
rinkResults.draws++;
}
});
return rinkResults;
}
/**
* Convert match to JSON
* @returns {Object} - JSON representation of the match
*/
toJSON() {
const jsonResult = this.result ? {
...this.result,
winner: this.getWinner(),
isDraw: this.isDraw()
} : null;
return {
_id: this._id,
homeTeam: this.homeTeam,
awayTeam: this.awayTeam,
date: this.date,
rink: this.rink,
result: jsonResult,
createdAt: this.createdAt,
updatedAt: this.updatedAt
};
}
}
/**
* League model representing a bowls league
*/
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) {
var _data$settings, _data$settings2, _data$settings3, _data$settings4, _data$settings5, _data$settings6, _data$settings7, _data$settings8;
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 = data.settings) === null || _data$settings === void 0 ? void 0 : _data$settings.pointsForWin) || 3,
pointsForDraw: ((_data$settings2 = data.settings) === null || _data$settings2 === void 0 ? void 0 : _data$settings2.pointsForDraw) || 1,
pointsForLoss: ((_data$settings3 = data.settings) === null || _data$settings3 === void 0 ? void 0 : _data$settings3.pointsForLoss) || 0,
promotionPositions: (_data$settings4 = data.settings) === null || _data$settings4 === void 0 ? void 0 : _data$settings4.promotionPositions,
relegationPositions: (_data$settings5 = data.settings) === null || _data$settings5 === void 0 ? void 0 : _data$settings5.relegationPositions,
timesTeamsPlayOther: ((_data$settings6 = data.settings) === null || _data$settings6 === void 0 ? void 0 : _data$settings6.timesTeamsPlayOther) || 2,
maxRinksPerSession: (_data$settings7 = data.settings) === null || _data$settings7 === void 0 ? void 0 : _data$settings7.maxRinksPerSession
};
if ((_data$settings8 = data.settings) !== null && _data$settings8 !== void 0 && _data$settings8.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 = {}) {
this.matches = []; // Clear any existing matches
if (this.teams.length < 2) {
return false; // Not enough teams to create fixtures
}
// Set default scheduling parameters
const {
schedulingPattern = 'interval',
intervalNumber = 1,
intervalUnit = 'weeks',
selectedDays = [],
maxMatchesPerDay = this.settings.maxRinksPerSession || null
} = schedulingParams;
// Prepare team list for scheduling. Add a dummy "BYE" team if the number of teams is odd.
let scheduleTeams = [...this.teams];
const BYE_TEAM_MARKER = {
_id: `__BYE_TEAM_INTERNAL_${Date.now()}__`,
name: 'BYE'
}; // Unique marker for the bye team
if (scheduleTeams.length % 2 !== 0) {
scheduleTeams.push(BYE_TEAM_MARKER);
}
const numEffectiveTeams = scheduleTeams.length; // This will now always be even
// Calculate the number of rounds needed for all unique pairings once
const roundsPerCycle = numEffectiveTeams - 1;
// Generate all matches first, then assign dates
const allMatches = [];
// The round-robin algorithm fixes one team and rotates the others.
// We'll fix the last team in the `scheduleTeams` list.
const fixedTeam = scheduleTeams[numEffectiveTeams - 1];
// The list of teams that will be rotated.
const initialRotatingTeams = scheduleTeams.slice(0, numEffectiveTeams - 1);
// Loop for each full set of fixtures (e.g., once for "home" games, once for "away" games if timesTeamsPlayOther is 2)
for (let cycle = 0; cycle < this.settings.timesTeamsPlayOther; cycle++) {
// For each cycle, re-initialize the rotating teams to ensure the same sequence of pairings.
let currentRotatingTeams = [...initialRotatingTeams];
for (let roundNum = 0; roundNum < roundsPerCycle; roundNum++) {
const conceptualPairsForThisRound = []; // Stores {team1, team2} for this specific round
// 1. Match involving the fixed team (e.g., scheduleTeams[n-1])
// Its opponent is the first team in the current rotated list.
const opponentForFixed = currentRotatingTeams[0];
if (fixedTeam._id !== BYE_TEAM_MARKER._id && opponentForFixed._id !== BYE_TEAM_MARKER._id) {
conceptualPairsForThisRound.push({
team1: fixedTeam,
team2: opponentForFixed
});
}
// 2. Other matches from the `currentRotatingTeams` list.
// The list has `numEffectiveTeams - 1` elements (an odd number).
// The first element `currentRotatingTeams[0]` is already paired with `fixedTeam`.
// The remaining elements `currentRotatingTeams[1]` to `currentRotatingTeams[length-1]` are paired up.
const rotatingListLength = currentRotatingTeams.length;
for (let j = 1; j <= (rotatingListLength - 1) / 2; j++) {
const teamA_idx = j;
const teamB_idx = rotatingListLength - j; // Pairs j-th from start with j-th from end (0-indexed list)
const teamA = currentRotatingTeams[teamA_idx];
const teamB = currentRotatingTeams[teamB_idx];
if (teamA._id !== BYE_TEAM_MARKER._id && teamB._id !== BYE_TEAM_MARKER._id) {
conceptualPairsForThisRound.push({
team1: teamA,
team2: teamB
});
}
}
// Store matches for this round
for (const pair of conceptualPairsForThisRound) {
let homeTeam, awayTeam;
// For even cycles (0, 2, ...), team1 is home. For odd cycles (1, 3, ...), team2 is home.
// This ensures that if pair (X,Y) is generated, cycle 0 schedules X vs Y, cycle 1 schedules Y vs X.
if (cycle % 2 === 0) {
homeTeam = pair.team1;
awayTeam = pair.team2;
} else {
homeTeam = pair.team2;
awayTeam = pair.team1;
}
allMatches.push({
homeTeam,
awayTeam,
roundKey: `${cycle}-${roundNum}`
});
}
// Rotate `currentRotatingTeams` for the next round: the last element moves to the front.
if (currentRotatingTeams.length > 1) {
// Rotation only makes sense for 2+ teams
const lastTeamInRotatingList = currentRotatingTeams.pop();
currentRotatingTeams.unshift(lastTeamInRotatingList);
}
}
}
// Now assign dates to matches based on scheduling pattern
if (startDate) {
this._assignMatchDates(allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay);
} else {
// No start date provided - create matches without dates but with rinks if configured
const assignedRinks = this._assignRinksToMatches(allMatches);
for (let i = 0; i < allMatches.length; i++) {
const matchData = allMatches[i];
const match = new Match({
homeTeam: matchData.homeTeam,
awayTeam: matchData.awayTeam,
date: null,
rink: assignedRinks[i]
});
if (!this.addMatch(match)) {
return false; // If addMatch fails (e.g., duplicate _id), abort.
}
}
}
return true;
}
/**
* Private helper method to assign dates to matches based on scheduling parameters
*/
_assignMatchDates(allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay) {
// Group matches by round for scheduling
const matchesByRound = {};
for (const matchData of allMatches) {
if (!matchesByRound[matchData.roundKey]) {
matchesByRound[matchData.roundKey] = [];
}
matchesByRound[matchData.roundKey].push(matchData);
}
// Sort round keys to ensure consistent scheduling order
const sortedRoundKeys = Object.keys(matchesByRound).sort();
let currentDate = new Date(startDate);
let isFirstRound = true;
for (const roundKey of sortedRoundKeys) {
const roundMatches = matchesByRound[roundKey];
if (maxMatchesPerDay && roundMatches.length > maxMatchesPerDay) {
// Split matches across multiple days if there's a limit
let matchIndex = 0;
while (matchIndex < roundMatches.length) {
const matchesForThisDate = roundMatches.slice(matchIndex, matchIndex + maxMatchesPerDay);
// Find the next valid date
const validDate = this._findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound);
// Assign rinks for matches on this date
const assignedRinks = this._assignRinksToMatches(matchesForThisDate);
// Create matches for this date
for (let i = 0; i < matchesForThisDate.length; i++) {
const matchData = matchesForThisDate[i];
const match = new Match({
homeTeam: matchData.homeTeam,
awayTeam: matchData.awayTeam,
date: new Date(validDate),
rink: assignedRinks[i]
});
if (!this.addMatch(match)) {
return false; // If addMatch fails, abort
}
}
matchIndex += maxMatchesPerDay;
isFirstRound = false;
// Move to next date for remaining matches
if (matchIndex < roundMatches.length) {
currentDate = this._getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);
}
}
// Set current date for next round
if (matchIndex >= roundMatches.length) {
currentDate = this._getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);
}
} else {
// All matches in this round can fit on one day
const validDate = this._findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound);
// Assign rinks for matches on this date
const assignedRinks = this._assignRinksToMatches(roundMatches);
// Create matches for this date
for (let i = 0; i < roundMatches.length; i++) {
const matchData = roundMatches[i];
const match = new Match({
homeTeam: matchData.homeTeam,
awayTeam: matchData.awayTeam,
date: new Date(validDate),
rink: assignedRinks[i]
});
if (!this.addMatch(match)) {
return false; // If addMatch fails, abort
}
}
isFirstRound = false;
// Move to next date for next round
currentDate = this._getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);
}
}
return true;
}
/**
* Private helper method to assign rinks to matches
* @param {Array} matches - Array of match data
* @returns {Array} - Array of assigned rink numbers (or null if no rinks assigned)
*/
_assignRinksToMatches(matches) {
if (!this.settings.maxRinksPerSession || this.settings.maxRinksPerSession < 1) {
// No rink assignment if maxRinksPerSession is not set or invalid
return matches.map(() => null);
}
const maxRinks = this.settings.maxRinksPerSession;
const assignedRinks = [];
// Create an array of available rink numbers
const availableRinks = Array.from({
length: maxRinks
}, (_, i) => i + 1);
for (let i = 0; i < matches.length; i++) {
if (availableRinks.length === 0) {
// If we've run out of available rinks, reset the pool
availableRinks.push(...Array.from({
length: maxRinks
}, (_, i) => i + 1));
}
// Randomly select a rink from available rinks
const randomIndex = Math.floor(Math.random() * availableRinks.length);
const selectedRink = availableRinks.splice(randomIndex, 1)[0];
assignedRinks.push(selectedRink);
}
return assignedRinks;
}
/**
* Find the next valid date based on scheduling pattern
*/
_findNextValidDate(fromDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound = false) {
if (isFirstRound) {
// For the first round, use the start date as-is if it's valid, otherwise find the next valid date
if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {
const currentDay = fromDate.getDay();
if (selectedDays.includes(currentDay)) {
return new Date(fromDate);
}
// Start date is not a valid day, find the next one
for (let i = 1; i < 7; i++) {
const checkDay = (currentDay + i) % 7;
if (selectedDays.includes(checkDay)) {
const nextDate = new Date(fromDate);
nextDate.setDate(nextDate.getDate() + i);
return nextDate;
}
}
}
return new Date(fromDate);
}
if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {
// Find the next occurrence of one of the selected days
const currentDay = fromDate.getDay();
// Find the next selected day (starting from tomorrow)
for (let i = 1; i <= 7; i++) {
const checkDay = (currentDay + i) % 7;
if (selectedDays.includes(checkDay)) {
const nextDate = new Date(fromDate);
nextDate.setDate(nextDate.getDate() + i);
return nextDate;
}
}
}
// Use interval pattern (or fallback)
return new Date(fromDate);
}
/**
* Get the next scheduling date after a match date
*/
_getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays) {
const nextDate = new Date(currentDate);
if (schedulingPattern === 'interval') {
// Add interval
if (intervalUnit === 'weeks') {
nextDate.setDate(nextDate.getDate() + intervalNumber * 7);
} else {
nextDate.setDate(nextDate.getDate() + intervalNumber);
}
} else if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {
// Move to next occurrence of selected days
const currentDay = currentDate.getDay();
let daysToAdd = 1; // Start from tomorrow
// Find the next selected day
for (let i = 1; i <= 7; i++) {
const checkDay = (currentDay + i) % 7;
if (selectedDays.includes(checkDay)) {
daysToAdd = i;
break;
}
}
nextDate.setDate(nextDate.getDate() + daysToAdd);
} else {
// Fallback to weekly
nextDate.setDate(nextDate.getDate() + 7);
}
return nextDate;
}
/**
* Returns the filtered list of matches requiring attention, sorted by priority.
* @returns {Array}
*/
getMatchesRequiringAttention() {
if (!this.matches || !Array.isArray(this.matches)) return [];
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayTimestamp = today.getTime();
// Scheduling conflict detection
const conflictingIds = this.getConflictingMatchIds();
const getPriority = match => {
const matchDateObj = match.date ? new Date(match.date) : null;
let matchTimestamp = null;
if (matchDateObj) {
matchDateObj.setHours(0, 0, 0, 0);
matchTimestamp = matchDateObj.getTime();
}
if (conflictingIds.has(match._id)) return 1;
if (match.result && matchTimestamp && matchTimestamp > todayTimestamp) return 2;
if (!match.result && matchTimestamp && matchTimestamp < todayTimestamp) return 3;
if (!match.date && !match.result) return 4;
return 5;
};
return this.matches.filter(match => {
const matchDateObj = match.date ? new Date(match.date) : null;
let matchTimestamp = null;
if (matchDateObj) {
matchDateObj.setHours(0, 0, 0, 0);
matchTimestamp = matchDateObj.getTime();
}
if (match.result && matchTimestamp && matchTimestamp > todayTimestamp) return true;
if (conflictingIds.has(match._id)) return true;
if (!match.result && matchTimestamp && matchTimestamp < todayTimestamp) return true;
if (!match.date && !match.result) return true;
return false;
}).sort((a, b) => {
var _a$homeTeam, _b$homeTeam, _a$awayTeam, _b$awayTeam;
const priorityA = getPriority(a);
const priorityB = getPriority(b);
if (priorityA !== priorityB) return priorityA - priorityB;
const homeTeamA = ((_a$homeTeam = a.homeTeam) === null || _a$homeTeam === void 0 ? void 0 : _a$homeTeam.name) || '';
const homeTeamB = ((_b$homeTeam = b.homeTeam) === null || _b$homeTeam === void 0 ? void 0 : _b$homeTeam.name) || '';
const awayTeamA = ((_a$awayTeam = a.awayTeam) === null || _a$awayTeam === void 0 ? void 0 : _a$awayTeam.name) || '';
const awayTeamB = ((_b$awayTeam = b.awayTeam) === null || _b$awayTeam === void 0 ? void 0 : _b$awayTeam.name) || '';
const homeCompare = homeTeamA.localeCompare(homeTeamB);
if (homeCompare !== 0) return homeCompare;
return awayTeamA.localeCompare(awayTeamB);
});
}
/**
* Returns a set of match IDs that are in scheduling conflict.
* @returns {Set<string>}
*/
getConflictingMatchIds() {
if (!this.matches) return new Set();
const today = new Date();
today.setHours(0, 0, 0, 0);
const todayTimestamp = today.getTime();
const futureFixtures = this.matches.filter(match => {
if (match.result || !match.date) return false;
const matchDate = new Date(match.date);
matchDate.setHours(0, 0, 0, 0);
return matchDate.getTime() >= todayTimestamp;
});
const matchesByDate = futureFixtures.reduce((acc, match) => {
const matchDate = new Date(match.date);
matchDate.setHours(0, 0, 0, 0);
const dateKey = matchDate.getTime();
if (!acc[dateKey]) acc[dateKey] = [];
acc[dateKey].push(match);
return acc;
}, {});
const conflictingIds = new Set();
for (const dateKey in matchesByDate) {
const matchesOnDay = matchesByDate[dateKey];
if (matchesOnDay.length < 2) continue;
// Check for team conflicts
const teamCounts = {};
matchesOnDay.forEach(match => {
var _match$homeTeam, _match$awayTeam;
const homeTeamId = (_match$homeTeam = match.homeTeam) === null || _match$homeTeam === void 0 ? void 0 : _match$homeTeam._id;
const awayTeamId = (_match$awayTeam = match.awayTeam) === null || _match$awayTeam === void 0 ? void 0 : _match$awayTeam._id;
if (homeTeamId) teamCounts[homeTeamId] = (teamCounts[homeTeamId] || 0) + 1;
if (awayTeamId) teamCounts[awayTeamId] = (teamCounts[awayTeamId] || 0) + 1;
});
const conflictingTeams = Object.keys(teamCounts).filter(teamId => teamCounts[teamId] > 1);
// Check for rink conflicts
const rinkCounts = {};
const matchesWithRinks = matchesOnDay.filter(match => match.rink != null);
matchesWithRinks.forEach(match => {
rinkCounts[match.rink] = (rinkCounts[match.rink] || 0) + 1;
});
const conflictingRinks = Object.keys(rinkCounts).filter(rink => rinkCounts[rink] > 1);
// Mark matches with team conflicts
if (conflictingTeams.length > 0) {
matchesOnDay.forEach(match => {
var _match$homeTeam2, _match$awayTeam2;
const homeTeamId = (_match$homeTeam2 = match.homeTeam) === null || _match$homeTeam2 === void 0 ? void 0 : _match$homeTeam2._id;
const awayTeamId = (_match$awayTeam2 = match.awayTeam) === null || _match$awayTeam2 === void 0 ? void 0 : _match$awayTeam2._id;
if (homeTeamId && conflictingTeams.includes(homeTeamId) || awayTeamId && conflictingTeams.includes(awayTeamId)) {
conflictingIds.add(match._id);
}
});
}
// Mark matches with rink conflicts
if (conflictingRinks.length > 0) {
matchesOnDay.forEach(match => {
if (match.rink != null && conflictingRinks.includes(match.rink.toString())) {
conflictingIds.add(match._id);
}
});
}
}
return conflictingIds;
}
/**
* 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
};
}
}
exports.League = League;
exports.Match = Match;
exports.Team = Team;
exports.validateLeague = validateLeague;
exports.validateMatch = validateMatch;
exports.validateTeam = validateTeam;
//# sourceMappingURL=index.cjs.map