@lovebowls/leaguejs
Version:
A framework-agnostic JavaScript library for managing leagues, teams, and matches
1 lines • 86.4 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../src/utils/validators.js","../src/utils/shared.js","../src/models/Team.js","../src/models/Match.js","../src/utils/leagueUtils.js","../src/models/League.js"],"sourcesContent":["/**\n * Validation utilities for the leagueJS\n */\nexport function validateLeague(data) {\n const errors = [];\n\n if (!data.name) {\n errors.push('League name is required');\n }\n\n if (data.settings) {\n if (typeof data.settings.pointsForWin !== 'undefined') {\n if (typeof data.settings.pointsForWin !== 'number' || data.settings.pointsForWin < 0) {\n errors.push('Invalid points settings');\n }\n }\n if (typeof data.settings.pointsForDraw !== 'undefined') {\n if (typeof data.settings.pointsForDraw !== 'number' || data.settings.pointsForDraw < 0) {\n errors.push('Invalid points settings');\n }\n }\n if (typeof data.settings.pointsForLoss !== 'undefined') {\n if (typeof data.settings.pointsForLoss !== 'number' || data.settings.pointsForLoss < 0) {\n errors.push('Invalid points settings');\n }\n }\n \n // Validate timesTeamsPlayOther\n if (typeof data.settings.timesTeamsPlayOther !== 'undefined') {\n if (typeof data.settings.timesTeamsPlayOther !== 'number' || \n data.settings.timesTeamsPlayOther < 1 || \n data.settings.timesTeamsPlayOther > 10) {\n errors.push('timesTeamsPlayOther must be an integer between 1 and 10');\n }\n }\n }\n\n return {\n isValid: errors.length === 0,\n errors\n };\n}\n\nexport function validateTeam(data) {\n const errors = [];\n\n if (!data._id || !data._id.trim()) {\n errors.push('Team ID is required');\n }\n\n return {\n isValid: errors.length === 0,\n errors\n };\n}\n\nexport function validateMatch(data) {\n const errors = [];\n\n if (!data.homeTeam || !data.homeTeam._id) {\n errors.push('Home team is required');\n }\n\n if (!data.awayTeam || !data.awayTeam._id) {\n errors.push('Away team is required');\n }\n\n if (data.date && !(data.date instanceof Date) && isNaN(new Date(data.date).getTime())) {\n errors.push('Invalid date format');\n }\n\n // Validate result scores if result object exists\n if (data.result) {\n if (typeof data.result.homeScore !== 'number' || data.result.homeScore < 0) {\n errors.push('Home score must be a non-negative number');\n }\n if (typeof data.result.awayScore !== 'number' || data.result.awayScore < 0) {\n errors.push('Away score must be a non-negative number');\n }\n // Optionally, could add validation for rinkScores structure here if needed\n }\n\n return {\n isValid: errors.length === 0,\n errors\n };\n} ","/**\n * Generates a GUID (Globally Unique Identifier)\n * @returns {string} A GUID string in the format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\n */\nexport function generateGUID() {\n // Generate random hex digits\n const hex = () => Math.floor(Math.random() * 16).toString(16);\n \n // Build GUID in format: 8-4-4-4-12\n return [\n // 8 hex digits\n Array(8).fill(0).map(hex).join(''),\n // 4 hex digits\n Array(4).fill(0).map(hex).join(''),\n // 4 hex digits\n Array(4).fill(0).map(hex).join(''),\n // 4 hex digits\n Array(4).fill(0).map(hex).join(''),\n // 12 hex digits\n Array(12).fill(0).map(hex).join('')\n ].join('-');\n}","/**\n * Team model representing a bowls team\n */\nimport { validateTeam } from '../utils/validators.js';\nimport { generateGUID } from '../utils/shared.js';\nexport class Team {\n /**\n * Create a new Team\n * @param {Object} data - Team data\n * @param {string} data._id - Unique identifier for the team\n * @param {string} [data.name] - Name of the team (defaults to _id if not provided)\n * @param {Date} [data.createdAt] - Creation date (defaults to current date)\n * @param {Date} [data.updatedAt] - Last update date (defaults to current date)\n */\n constructor(data) {\n const validationResult = validateTeam(data);\n if (!validationResult.isValid) {\n throw new Error(validationResult.errors[0]);\n }\n\n this._id = data._id || generateGUID();\n this.name = data.name || data._id;\n this.createdAt = data.createdAt || new Date();\n this.updatedAt = data.updatedAt || new Date();\n }\n\n /**\n * Update team details\n * @param {Object} updates - Updated team details\n */\n update(updates) {\n Object.assign(this.details, updates);\n this.updatedAt = new Date();\n }\n\n /**\n * Convert team to JSON\n * @returns {Object} - JSON representation of the team\n */\n toJSON() {\n return {\n _id: this._id,\n name: this.name,\n createdAt: this.createdAt,\n updatedAt: this.updatedAt\n };\n }\n} ","/**\n * Match model representing a bowls match between two teams\n * @typedef {Object} MatchData\n * @property {Object} homeTeam - Home team object with _id property\n * @property {Object} awayTeam - Away team object with _id property\n * @property {string} [_id] - Unique identifier for the match\n * @property {Date|string|null} [date] - Match date (can be null for unscheduled matches)\n * @property {number} [rink] - Assigned rink number for the match\n * @property {Object} [result] - Optional match result data containing scores\n * @property {number} [result.homeScore] - Home team's score\n * @property {number} [result.awayScore] - Away team's score\n * @property {Array<Object>} [result.rinkScores] - Optional individual rink scores\n * @property {Date} [createdAt] - Creation date (defaults to current date)\n * @property {Date} [updatedAt] - Last update date (defaults to current date)\n */\nimport { validateMatch } from '../utils/validators.js';\nimport { generateGUID } from '../utils/shared.js';\n\nexport class Match {\n /**\n * Create a new Match\n * @param {Object} data - Match data\n * @param {Object} data.homeTeam - Home team object with _id property\n * @param {Object} data.awayTeam - Away team object with _id property\n * @param {Date|string|null} [data.date] - Match date (can be null for unscheduled matches)\n * @param {number} [data.rink] - Assigned rink number for the match\n * @param {Object} [data.result] - Optional match result data containing scores\n * @param {number} [data.result.homeScore] - Home team's score\n * @param {number} [data.result.awayScore] - Away team's score\n * @param {Array<Object>} [data.result.rinkScores] - Optional individual rink scores\n * @param {Date} [data.createdAt] - Creation date (defaults to current date)\n * @param {Date} [data.updatedAt] - Last update date (defaults to current date)\n */\n constructor(data) {\n const validationResult = validateMatch(data);\n if (!validationResult.isValid) {\n throw new Error(validationResult.errors[0]);\n }\n\n if (data.homeTeam._id === data.awayTeam._id) {\n throw new Error('Home and away teams must be different');\n }\n \n this._id = data._id || generateGUID();\n this.homeTeam = data.homeTeam;\n this.awayTeam = data.awayTeam;\n this.date = data.date ? new Date(data.date) : null;\n this.rink = data.rink || null;\n this.createdAt = data.createdAt || new Date();\n this.updatedAt = data.updatedAt || new Date();\n\n // Process result if scores are provided\n if (data.result && typeof data.result.homeScore === 'number' && typeof data.result.awayScore === 'number') {\n this.result = {\n homeScore: data.result.homeScore,\n awayScore: data.result.awayScore,\n rinkScores: data.result.rinkScores || null\n };\n } else {\n this.result = null; // Ensure result is null if scores are not provided\n }\n }\n\n /**\n * Get the home team name\n * @returns {string} - The name of the home team\n */\n get homeTeamName() {\n return this.homeTeam.name;\n }\n\n /**\n * Get the away team name\n * @returns {string} - The name of the away team\n */\n get awayTeamName() {\n return this.awayTeam.name;\n }\n\n /**\n * Determines the winner of the match based on scores.\n * @returns {string|null} - The name of the winning team, 'draw', or null if no result is set.\n */\n getWinner() {\n if (!this.result) {\n return null;\n }\n if (this.result.homeScore > this.result.awayScore) {\n return this.homeTeamName;\n }\n if (this.result.awayScore > this.result.homeScore) {\n return this.awayTeamName;\n }\n return 'draw';\n }\n\n /**\n * Checks if the match resulted in a draw.\n * @returns {boolean|null} - True if it's a draw, false otherwise, or null if no result is set.\n */\n isDraw() {\n if (!this.result) {\n return null;\n }\n return this.result.homeScore === this.result.awayScore;\n }\n\n /**\n * Set rink scores for an existing match result\n * @param {Array} rinkScores - Array of rink scores [{homeScore, awayScore}, ...]\n * @returns {boolean} - True if scores were set, false if no result exists\n */\n setRinkScores(rinkScores) {\n if (!this.result) return false;\n \n this.result.rinkScores = rinkScores;\n this.updatedAt = new Date();\n return true;\n }\n\n /**\n * Get rink win/draw counts\n * @returns {Object|null} - Object with rink win counts or null if no rink scores\n */\n getRinkResults() {\n if (!this.result || !this.result.rinkScores) return null;\n \n const rinkResults = {\n homeWins: 0,\n awayWins: 0,\n draws: 0,\n total: this.result.rinkScores.length\n };\n \n this.result.rinkScores.forEach(rink => {\n if (rink.homeScore > rink.awayScore) {\n rinkResults.homeWins++;\n } else if (rink.awayScore > rink.homeScore) {\n rinkResults.awayWins++;\n } else {\n rinkResults.draws++;\n }\n });\n \n return rinkResults;\n }\n\n /**\n * Convert match to JSON\n * @returns {Object} - JSON representation of the match\n */\n toJSON() {\n const jsonResult = this.result ? {\n ...this.result,\n winner: this.getWinner(),\n isDraw: this.isDraw()\n } : null;\n\n return {\n _id: this._id,\n homeTeam: this.homeTeam,\n awayTeam: this.awayTeam,\n date: this.date,\n rink: this.rink,\n result: jsonResult,\n createdAt: this.createdAt,\n updatedAt: this.updatedAt\n };\n }\n} ","import { Match } from '../models/Match.js';\r\n\r\n/**\r\n * Find the next valid date based on scheduling pattern\r\n */\r\nfunction _findNextValidDate(fromDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound = false) {\r\n if (isFirstRound) {\r\n // For the first round, use the start date as-is if it's valid, otherwise find the next valid date\r\n if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {\r\n const currentDay = fromDate.getDay();\r\n if (selectedDays.includes(currentDay)) {\r\n return new Date(fromDate);\r\n }\r\n // Start date is not a valid day, find the next one\r\n for (let i = 1; i < 7; i++) {\r\n const checkDay = (currentDay + i) % 7;\r\n if (selectedDays.includes(checkDay)) {\r\n const nextDate = new Date(fromDate);\r\n nextDate.setDate(nextDate.getDate() + i);\r\n return nextDate;\r\n }\r\n }\r\n }\r\n return new Date(fromDate);\r\n }\r\n\r\n if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {\r\n // Find the next occurrence of one of the selected days\r\n const currentDay = fromDate.getDay();\r\n\r\n // Find the next selected day (starting from tomorrow)\r\n for (let i = 1; i <= 7; i++) {\r\n const checkDay = (currentDay + i) % 7;\r\n if (selectedDays.includes(checkDay)) {\r\n const nextDate = new Date(fromDate);\r\n nextDate.setDate(nextDate.getDate() + i);\r\n return nextDate;\r\n }\r\n }\r\n }\r\n\r\n // Use interval pattern (or fallback)\r\n return new Date(fromDate);\r\n}\r\n\r\n/**\r\n * Get the next scheduling date after a match date\r\n */\r\nfunction _getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays) {\r\n const nextDate = new Date(currentDate);\r\n\r\n if (schedulingPattern === 'interval') {\r\n // Add interval\r\n if (intervalUnit === 'weeks') {\r\n nextDate.setDate(nextDate.getDate() + (intervalNumber * 7));\r\n } else {\r\n nextDate.setDate(nextDate.getDate() + intervalNumber);\r\n }\r\n } else if (schedulingPattern === 'dayOfWeek' && selectedDays.length > 0) {\r\n // Move to next occurrence of selected days\r\n const currentDay = currentDate.getDay();\r\n let daysToAdd = 1; // Start from tomorrow\r\n\r\n // Find the next selected day\r\n for (let i = 1; i <= 7; i++) {\r\n const checkDay = (currentDay + i) % 7;\r\n if (selectedDays.includes(checkDay)) {\r\n daysToAdd = i;\r\n break;\r\n }\r\n }\r\n\r\n nextDate.setDate(nextDate.getDate() + daysToAdd);\r\n } else {\r\n // Fallback to weekly\r\n nextDate.setDate(nextDate.getDate() + 7);\r\n }\r\n\r\n return nextDate;\r\n}\r\n\r\n/**\r\n * Private helper method to assign rinks to matches\r\n * @param {object} league - The league instance\r\n * @param {Array} matches - Array of match data\r\n * @returns {Array} - Array of assigned rink numbers (or null if no rinks assigned)\r\n */\r\nfunction _assignRinksToMatches(league, matches) {\r\n if (!league.settings.maxRinksPerSession || league.settings.maxRinksPerSession < 1) {\r\n // No rink assignment if maxRinksPerSession is not set or invalid\r\n return matches.map(() => null);\r\n }\r\n\r\n const maxRinks = league.settings.maxRinksPerSession;\r\n const assignedRinks = [];\r\n\r\n // Create an array of available rink numbers\r\n const availableRinks = Array.from({\r\n length: maxRinks\r\n }, (_, i) => i + 1);\r\n\r\n for (let i = 0; i < matches.length; i++) {\r\n if (availableRinks.length === 0) {\r\n // If we've run out of available rinks, reset the pool\r\n availableRinks.push(...Array.from({\r\n length: maxRinks\r\n }, (_, i) => i + 1));\r\n }\r\n\r\n // Randomly select a rink from available rinks\r\n const randomIndex = Math.floor(Math.random() * availableRinks.length);\r\n const selectedRink = availableRinks.splice(randomIndex, 1)[0];\r\n assignedRinks.push(selectedRink);\r\n }\r\n\r\n return assignedRinks;\r\n}\r\n\r\n/**\r\n * Private helper method to assign dates to matches based on scheduling parameters\r\n */\r\nfunction _assignMatchDates(league, allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay) {\r\n // Group matches by round for scheduling\r\n const matchesByRound = {};\r\n for (const matchData of allMatches) {\r\n if (!matchesByRound[matchData.roundKey]) {\r\n matchesByRound[matchData.roundKey] = [];\r\n }\r\n matchesByRound[matchData.roundKey].push(matchData);\r\n }\r\n\r\n // Sort round keys to ensure consistent scheduling order\r\n const sortedRoundKeys = Object.keys(matchesByRound).sort();\r\n\r\n let currentDate = new Date(startDate);\r\n let isFirstRound = true;\r\n\r\n for (const roundKey of sortedRoundKeys) {\r\n const roundMatches = matchesByRound[roundKey];\r\n\r\n if (maxMatchesPerDay && roundMatches.length > maxMatchesPerDay) {\r\n // Split matches across multiple days if there's a limit\r\n let matchIndex = 0;\r\n\r\n while (matchIndex < roundMatches.length) {\r\n const matchesForThisDate = roundMatches.slice(matchIndex, matchIndex + maxMatchesPerDay);\r\n\r\n // Find the next valid date\r\n const validDate = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound);\r\n\r\n // Assign rinks for matches on this date\r\n const assignedRinks = _assignRinksToMatches(league, matchesForThisDate);\r\n\r\n // Create matches for this date\r\n for (let i = 0; i < matchesForThisDate.length; i++) {\r\n const matchData = matchesForThisDate[i];\r\n const match = new Match({\r\n homeTeam: matchData.homeTeam,\r\n awayTeam: matchData.awayTeam,\r\n date: new Date(validDate),\r\n rink: assignedRinks[i]\r\n });\r\n\r\n if (!league.addMatch(match)) {\r\n return false; // If addMatch fails, abort\r\n }\r\n }\r\n\r\n matchIndex += maxMatchesPerDay;\r\n isFirstRound = false;\r\n\r\n // Move to next date for remaining matches\r\n if (matchIndex < roundMatches.length) {\r\n currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);\r\n }\r\n }\r\n\r\n // Set current date for next round\r\n if (matchIndex >= roundMatches.length) {\r\n currentDate = _getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);\r\n }\r\n } else {\r\n // All matches in this round can fit on one day\r\n const validDate = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound);\r\n\r\n // Assign rinks for matches on this date\r\n const assignedRinks = _assignRinksToMatches(league, roundMatches);\r\n\r\n // Create matches for this date\r\n for (let i = 0; i < roundMatches.length; i++) {\r\n const matchData = roundMatches[i];\r\n const match = new Match({\r\n homeTeam: matchData.homeTeam,\r\n awayTeam: matchData.awayTeam,\r\n date: new Date(validDate),\r\n rink: assignedRinks[i]\r\n });\r\n\r\n if (!league.addMatch(match)) {\r\n return false; // If addMatch fails, abort\r\n }\r\n }\r\n\r\n isFirstRound = false;\r\n // Move to next date for next round\r\n currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays);\r\n }\r\n }\r\n\r\n return true;\r\n}\r\n\r\n\r\n/**\r\n * Initialise fixtures for the league using a round-robin algorithm.\r\n * Each team plays once per match day. Fixtures are scheduled according to the provided scheduling parameters.\r\n * Teams will play each other `this.settings.timesTeamsPlayOther` times, with home and away fixtures balanced\r\n * as per the cycles of the round-robin generation (e.g., first cycle A vs B, second cycle B vs A).\r\n *\r\n * @param {object} league - The league instance\r\n * @param {Date} [startDate] - The start date for the first round of matches. If null, matches will have null dates.\r\n * @param {Object} [schedulingParams] - Advanced scheduling parameters\r\n * @param {string} [schedulingParams.schedulingPattern='interval'] - 'interval' or 'dayOfWeek'\r\n * @param {number} [schedulingParams.intervalNumber=1] - Number of interval units between match days\r\n * @param {string} [schedulingParams.intervalUnit='weeks'] - 'days' or 'weeks'\r\n * @param {Array<number>} [schedulingParams.selectedDays] - Array of day numbers (0=Sunday, 1=Monday, etc.) for dayOfWeek pattern\r\n * @param {number} [schedulingParams.maxMatchesPerDay] - Maximum matches per day (defaults to maxRinksPerSession if not provided)\r\n * @returns {boolean} - True if fixtures were successfully created, false if there are fewer than 2 teams or if a match fails to be added.\r\n */\r\nexport function initialiseFixtures(league, startDate, schedulingParams = {}) {\r\n league.matches = []; // Clear any existing matches\r\n\r\n if (league.teams.length < 2) {\r\n return false; // Not enough teams to create fixtures\r\n }\r\n\r\n // Set default scheduling parameters\r\n const {\r\n schedulingPattern = 'interval',\r\n intervalNumber = 1,\r\n intervalUnit = 'weeks',\r\n selectedDays = [],\r\n maxMatchesPerDay = league.settings.maxRinksPerSession || null\r\n } = schedulingParams;\r\n\r\n // Prepare team list for scheduling. Add a dummy \"BYE\" team if the number of teams is odd.\r\n let scheduleTeams = [...league.teams];\r\n const BYE_TEAM_MARKER = {\r\n _id: `__BYE_TEAM_INTERNAL_${Date.now()}__`,\r\n name: 'BYE'\r\n }; // Unique marker for the bye team\r\n if (scheduleTeams.length % 2 !== 0) {\r\n scheduleTeams.push(BYE_TEAM_MARKER);\r\n }\r\n const numEffectiveTeams = scheduleTeams.length; // This will now always be even\r\n\r\n // Calculate the number of rounds needed for all unique pairings once\r\n const roundsPerCycle = numEffectiveTeams - 1;\r\n\r\n // Generate all matches first, then assign dates\r\n const allMatches = [];\r\n\r\n // The round-robin algorithm fixes one team and rotates the others.\r\n // We'll fix the last team in the `scheduleTeams` list.\r\n const fixedTeam = scheduleTeams[numEffectiveTeams - 1];\r\n // The list of teams that will be rotated.\r\n const initialRotatingTeams = scheduleTeams.slice(0, numEffectiveTeams - 1);\r\n\r\n // Loop for each full set of fixtures (e.g., once for \"home\" games, once for \"away\" games if timesTeamsPlayOther is 2)\r\n for (let cycle = 0; cycle < league.settings.timesTeamsPlayOther; cycle++) {\r\n // For each cycle, re-initialize the rotating teams to ensure the same sequence of pairings.\r\n let currentRotatingTeams = [...initialRotatingTeams];\r\n\r\n for (let roundNum = 0; roundNum < roundsPerCycle; roundNum++) {\r\n const conceptualPairsForThisRound = []; // Stores {team1, team2} for this specific round\r\n\r\n // 1. Match involving the fixed team (e.g., scheduleTeams[n-1])\r\n // Its opponent is the first team in the current rotated list.\r\n const opponentForFixed = currentRotatingTeams[0];\r\n if (fixedTeam._id !== BYE_TEAM_MARKER._id && opponentForFixed._id !== BYE_TEAM_MARKER._id) {\r\n // For balanced home/away games, the fixed team should alternate home/away status in each round\r\n if (roundNum % 2 === 0) {\r\n conceptualPairsForThisRound.push({ team1: opponentForFixed, team2: fixedTeam }); // fixed team is away\r\n } else {\r\n conceptualPairsForThisRound.push({ team1: fixedTeam, team2: opponentForFixed }); // fixed team is home\r\n }\r\n }\r\n\r\n // 2. Other matches from the `currentRotatingTeams` list.\r\n // The list has `numEffectiveTeams - 1` elements (an odd number).\r\n // The first element `currentRotatingTeams[0]` is already paired with `fixedTeam`.\r\n // The remaining elements `currentRotatingTeams[1]` to `currentRotatingTeams[length-1]` are paired up.\r\n const rotatingListLength = currentRotatingTeams.length;\r\n for (let j = 1; j <= (rotatingListLength - 1) / 2; j++) {\r\n const teamA_idx = j;\r\n const teamB_idx = rotatingListLength - j; // Pairs j-th from start with j-th from end (0-indexed list)\r\n\r\n const teamA = currentRotatingTeams[teamA_idx];\r\n const teamB = currentRotatingTeams[teamB_idx];\r\n\r\n if (teamA._id !== BYE_TEAM_MARKER._id && teamB._id !== BYE_TEAM_MARKER._id) {\r\n conceptualPairsForThisRound.push({\r\n team1: teamA,\r\n team2: teamB\r\n });\r\n }\r\n }\r\n\r\n // Store matches for this round\r\n for (const pair of conceptualPairsForThisRound) {\r\n let homeTeam, awayTeam;\r\n // For even cycles (0, 2, ...), team1 is home. For odd cycles (1, 3, ...), team2 is home.\r\n // This ensures that if pair (X,Y) is generated, cycle 0 schedules X vs Y, cycle 1 schedules Y vs X.\r\n if (cycle % 2 === 0) {\r\n homeTeam = pair.team1;\r\n awayTeam = pair.team2;\r\n } else {\r\n homeTeam = pair.team2;\r\n awayTeam = pair.team1;\r\n }\r\n\r\n allMatches.push({\r\n homeTeam,\r\n awayTeam,\r\n roundKey: `${cycle}-${roundNum}`\r\n });\r\n }\r\n\r\n // Rotate `currentRotatingTeams` for the next round: the last element moves to the front.\r\n if (currentRotatingTeams.length > 1) { // Rotation only makes sense for 2+ teams\r\n const lastTeamInRotatingList = currentRotatingTeams.pop();\r\n currentRotatingTeams.unshift(lastTeamInRotatingList);\r\n }\r\n }\r\n }\r\n\r\n // Now assign dates to matches based on scheduling pattern\r\n if (startDate) {\r\n _assignMatchDates(league, allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay);\r\n } else {\r\n // No start date provided - create matches without dates but with rinks if configured\r\n const assignedRinks = _assignRinksToMatches(league, allMatches);\r\n\r\n for (let i = 0; i < allMatches.length; i++) {\r\n const matchData = allMatches[i];\r\n const match = new Match({\r\n homeTeam: matchData.homeTeam,\r\n awayTeam: matchData.awayTeam,\r\n date: null,\r\n rink: assignedRinks[i]\r\n });\r\n\r\n if (!league.addMatch(match)) {\r\n return false; // If addMatch fails (e.g., duplicate _id), abort.\r\n }\r\n }\r\n }\r\n\r\n return true;\r\n}\r\n\r\n\r\n/**\r\n * Returns a set of match IDs that are in scheduling conflict.\r\n * @param {object} league - The league instance\r\n * @returns {Set<string>}\r\n */\r\nexport function getConflictingMatchIds(league) {\r\n if (!league.matches) return new Set();\r\n const today = new Date();\r\n today.setHours(0, 0, 0, 0);\r\n const todayTimestamp = today.getTime();\r\n const futureFixtures = league.matches.filter(match => {\r\n if (match.result || !match.date) return false;\r\n const matchDate = new Date(match.date);\r\n matchDate.setHours(0, 0, 0, 0);\r\n return matchDate.getTime() >= todayTimestamp;\r\n });\r\n const matchesByDate = futureFixtures.reduce((acc, match) => {\r\n const matchDate = new Date(match.date);\r\n matchDate.setHours(0, 0, 0, 0);\r\n const dateKey = matchDate.getTime();\r\n if (!acc[dateKey]) acc[dateKey] = [];\r\n acc[dateKey].push(match);\r\n return acc;\r\n }, {});\r\n const conflictingIds = new Set();\r\n for (const dateKey in matchesByDate) {\r\n const matchesOnDay = matchesByDate[dateKey];\r\n if (matchesOnDay.length < 2) continue;\r\n\r\n // Check for team conflicts\r\n const teamCounts = {};\r\n matchesOnDay.forEach(match => {\r\n const homeTeamId = match.homeTeam?._id;\r\n const awayTeamId = match.awayTeam?._id;\r\n if (homeTeamId) teamCounts[homeTeamId] = (teamCounts[homeTeamId] || 0) + 1;\r\n if (awayTeamId) teamCounts[awayTeamId] = (teamCounts[awayTeamId] || 0) + 1;\r\n });\r\n const conflictingTeams = Object.keys(teamCounts).filter(teamId => teamCounts[teamId] > 1);\r\n\r\n // Check for rink conflicts\r\n const rinkCounts = {};\r\n const matchesWithRinks = matchesOnDay.filter(match => match.rink != null);\r\n matchesWithRinks.forEach(match => {\r\n rinkCounts[match.rink] = (rinkCounts[match.rink] || 0) + 1;\r\n });\r\n const conflictingRinks = Object.keys(rinkCounts).filter(rink => rinkCounts[rink] > 1);\r\n\r\n // Mark matches with team conflicts\r\n if (conflictingTeams.length > 0) {\r\n matchesOnDay.forEach(match => {\r\n const homeTeamId = match.homeTeam?._id;\r\n const awayTeamId = match.awayTeam?._id;\r\n if ((homeTeamId && conflictingTeams.includes(homeTeamId)) ||\r\n (awayTeamId && conflictingTeams.includes(awayTeamId))) {\r\n conflictingIds.add(match._id);\r\n }\r\n });\r\n }\r\n\r\n // Mark matches with rink conflicts\r\n if (conflictingRinks.length > 0) {\r\n matchesOnDay.forEach(match => {\r\n if (match.rink != null && conflictingRinks.includes(match.rink.toString())) {\r\n conflictingIds.add(match._id);\r\n }\r\n });\r\n }\r\n }\r\n return conflictingIds;\r\n}\r\n\r\n/**\r\n * Returns the filtered list of matches requiring attention, sorted by priority.\r\n * @param {object} league - The league instance\r\n * @returns {Array}\r\n */\r\nexport function getMatchesRequiringAttention(league) {\r\n if (!league.matches || !Array.isArray(league.matches)) return [];\r\n const today = new Date();\r\n today.setHours(0, 0, 0, 0);\r\n const todayTimestamp = today.getTime();\r\n // Scheduling conflict detection\r\n const conflictingIds = getConflictingMatchIds(league);\r\n const getPriority = (match) => {\r\n const matchDateObj = match.date ? new Date(match.date) : null;\r\n let matchTimestamp = null;\r\n if (matchDateObj) {\r\n matchDateObj.setHours(0, 0, 0, 0);\r\n matchTimestamp = matchDateObj.getTime();\r\n }\r\n if (conflictingIds.has(match._id)) return 1;\r\n if (match.result && matchTimestamp && matchTimestamp > todayTimestamp) return 2;\r\n if (!match.result && matchTimestamp && matchTimestamp < todayTimestamp) return 3;\r\n if (!match.date && !match.result) return 4;\r\n return 5;\r\n };\r\n return league.matches\r\n .filter(match => {\r\n const matchDateObj = match.date ? new Date(match.date) : null;\r\n let matchTimestamp = null;\r\n if (matchDateObj) {\r\n matchDateObj.setHours(0, 0, 0, 0);\r\n matchTimestamp = matchDateObj.getTime();\r\n }\r\n if (match.result && matchTimestamp && matchTimestamp > todayTimestamp) return true;\r\n if (conflictingIds.has(match._id)) return true;\r\n if (!match.result && matchTimestamp && matchTimestamp < todayTimestamp) return true;\r\n if (!match.date && !match.result) return true;\r\n return false;\r\n })\r\n .sort((a, b) => {\r\n const priorityA = getPriority(a);\r\n const priorityB = getPriority(b);\r\n if (priorityA !== priorityB) return priorityA - priorityB;\r\n const homeTeamA = a.homeTeam?.name || '';\r\n const homeTeamB = b.homeTeam?.name || '';\r\n const awayTeamA = a.awayTeam?.name || '';\r\n const awayTeamB = b.awayTeam?.name || '';\r\n const homeCompare = homeTeamA.localeCompare(homeTeamB);\r\n if (homeCompare !== 0) return homeCompare;\r\n return awayTeamA.localeCompare(awayTeamB);\r\n });\r\n}\r\n","/**\n * League model representing a bowls league\n */\nimport { Team } from './Team.js';\nimport { Match } from './Match.js';\nimport { validateLeague } from '../utils/validators.js';\nimport { generateGUID } from '../utils/shared.js';\nimport {\n initialiseFixtures as initialiseFixturesUtil,\n getMatchesRequiringAttention as getMatchesRequiringAttentionUtil,\n getConflictingMatchIds as getConflictingMatchIdsUtil\n} from '../utils/leagueUtils.js';\n\nexport class League {\n /**\n * Create a League instance from JSON data\n * @param {Object|string} jsonData - League data as JSON object or string\n * @returns {League} - New League instance\n */\n static fromJSON(jsonData) {\n try {\n const data = typeof jsonData === 'string' ? JSON.parse(jsonData) : jsonData;\n return new League(data);\n } catch (error) {\n throw new Error(`Failed to load league: ${error.message}`);\n }\n }\n\n /**\n * Create a new League\n * @param {Object} data - League data\n * @param {string} [data._id] - Unique identifier for the league (auto-generated if not provided)\n * @param {string} data.name - Name of the league\n * @param {Object} [data.settings] - League settings\n * @param {number} [data.settings.pointsForWin=3] - Points awarded for a win\n * @param {number} [data.settings.pointsForDraw=1] - Points awarded for a draw\n * @param {number} [data.settings.pointsForLoss=0] - Points awarded for a loss\n * @param {number} [data.settings.promotionPositions] - Number of teams that get promoted\n * @param {number} [data.settings.relegationPositions] - Number of teams that get relegated\n * @param {number} [data.settings.timesTeamsPlayOther=2] - Number of times teams play against each other (1-10)\n * @param {number} [data.settings.maxRinksPerSession] - Maximum number of rinks available per session\n * @param {Object} [data.settings.rinkPoints] - Settings for rink-based scoring\n * @param {number} [data.settings.rinkPoints.pointsPerRinkWin=2] - Points awarded per winning rink\n * @param {number} [data.settings.rinkPoints.pointsPerRinkDraw=1] - Points awarded per drawn rink\n * @param {number} [data.settings.rinkPoints.defaultRinks=4] - Default number of rinks per match\n * @param {boolean} [data.settings.rinkPoints.enabled=false] - Whether rink points are enabled\n * @param {Array<Team>} [data.teams=[]] - Initial list of teams\n * @param {Array<>} [data.matches=[]] - Initial list of matches\n * @param {Date} [data.createdAt] - Creation date (defaults to current date)\n * @param {Date} [data.updatedAt] - Last update date (defaults to current date)\n */\n constructor(data) {\n const validationResult = validateLeague(data);\n if (!validationResult.isValid) {\n throw new Error(`Invalid league data: ${validationResult.errors.join(', ')}`);\n }\n\n this._id = data._id || generateGUID();\n this.name = data.name;\n this.settings = {\n pointsForWin: data.settings?.pointsForWin || 3,\n pointsForDraw: data.settings?.pointsForDraw || 1,\n pointsForLoss: data.settings?.pointsForLoss || 0,\n promotionPositions: data.settings?.promotionPositions,\n relegationPositions: data.settings?.relegationPositions,\n timesTeamsPlayOther: data.settings?.timesTeamsPlayOther || 2,\n maxRinksPerSession: data.settings?.maxRinksPerSession\n };\n \n if (data.settings?.rinkPoints) {\n this.settings.rinkPoints = {\n pointsPerRinkWin: data.settings.rinkPoints.pointsPerRinkWin || 2,\n pointsPerRinkDraw: data.settings.rinkPoints.pointsPerRinkDraw || 1,\n defaultRinks: data.settings.rinkPoints.defaultRinks || 4,\n enabled: data.settings.rinkPoints.enabled || false\n };\n }\n this.teams = (data.teams || []).map(team => new Team(team));\n this.matches = (data.matches || []).map(match => new Match(match));\n this.createdAt = data.createdAt || new Date();\n this.updatedAt = data.updatedAt || new Date();\n }\n\n /**\n * Add a team to the league\n * @param {Team|Object} team - Team to add (either a Team instance or team data)\n * @returns {boolean} - True if team was added, false if already exists\n * @throws {Error} - If team data is invalid\n */\n addTeam(team) {\n // If team is not already a Team instance, try to create one (which will validate the data)\n const teamInstance = team instanceof Team ? team : new Team(team);\n \n // Check if team with same ID already exists\n if (!this.teams.some(t => t._id === teamInstance._id)) {\n this.teams.push(teamInstance);\n this.updatedAt = new Date();\n return true;\n }\n return false;\n }\n\n /**\n * Remove a team from the league\n * @param {string} teamId - ID of team to remove\n * @returns {boolean} - True if team was removed, false if not found\n */\n removeTeam(teamId) {\n const initialLength = this.teams.length;\n this.teams = this.teams.filter(team => team._id !== teamId);\n \n if (this.teams.length !== initialLength) {\n this.updatedAt = new Date();\n return true;\n }\n return false;\n }\n\n /**\n * Add a match to the league\n * @param {Match|Object} match - Match instance or match data\n * @returns {boolean} - True if match was added, false otherwise\n * @throws {Error} - If match data is invalid\n */\n addMatch(match) {\n // If match is not already a Match instance, try to create one (which will validate the data)\n const matchInstance = match instanceof Match ? match : new Match(match);\n \n // Validate that both teams are in this league using IDs\n const homeTeamExists = this.teams.some(team => team._id === matchInstance.homeTeam._id);\n const awayTeamExists = this.teams.some(team => team._id === matchInstance.awayTeam._id);\n \n if (homeTeamExists && awayTeamExists) {\n // Check if a match with the same _id already exists\n if (this.matches.some(m => m._id === matchInstance._id)) {\n return false; // Match already exists\n }\n \n this.matches.push(matchInstance);\n this.updatedAt = new Date();\n return true;\n }\n return false;\n }\n\n /**\n * Get a team by its ID\n * @param {string} teamId - ID of the team\n * @returns {Team|undefined} - The team if found, undefined otherwise\n */\n getTeam(teamId) {\n return this.teams.find(team => team._id === teamId);\n }\n\n /**\n * Get a match by its ID\n * @param {string} matchId - The ID of the match\n * @returns {Match|undefined} - The match if found, undefined otherwise\n */\n getMatch(matchId) {\n return this.matches.find(match => match._id === matchId);\n }\n\n /**\n * Get all matches for a team\n * @param {string} teamId - ID of the team\n * @returns {Array<Match>} - Array of matches involving the team\n */\n getTeamMatches(teamId) {\n const team = this.getTeam(teamId);\n if (!team) {\n throw new Error(`Team not found with ID: ${teamId}`);\n }\n\n return this.matches.filter(match => \n match.homeTeam._id === teamId || match.awayTeam._id === teamId\n );\n }\n\n /**\n * Get statistics for a team\n * @param {string} teamId - ID of the team\n * @returns {Object} - Team statistics\n */\n getTeamStats(teamId) {\n const team = this.getTeam(teamId);\n if (!team) {\n throw new Error(`Team not found with ID: ${teamId}`);\n }\n\n const matches = this.getTeamMatches(teamId);\n const stats = {\n teamId: teamId,\n teamName: team.name,\n played: 0,\n won: 0,\n drawn: 0,\n lost: 0,\n shotsFor: 0,\n shotsAgainst: 0,\n points: 0,\n inPromotionPosition: false,\n inRelegationPosition: false\n };\n\n // Sort matches by date to determine form/recent matches\n const playedMatches = matches\n .filter(match => match.result && match.date) // Ensure match has result and date\n .sort((a, b) => new Date(a.date) - new Date(b.date)); // Sort oldest to newest\n\n const recentMatchDetails = []; // Array to store details for tooltip\n\n playedMatches.forEach(match => {\n // Ensure result object and scores exist\n if (!match.result || typeof match.result.homeScore !== 'number' || typeof match.result.awayScore !== 'number') {\n console.warn(`Match ${match._id} for team ${team.name} missing valid result or scores.`);\n return; // Skip this match if data is incomplete\n }\n\n const isHome = match.homeTeam._id === teamId;\n const teamScore = isHome ? match.result.homeScore : match.result.awayScore;\n const opponentScore = isHome ? match.result.awayScore : match.result.homeScore;\n // const opponentName = isHome ? match.awayTeamName : match.homeTeamName; // Not needed for description format below\n\n stats.played++;\n stats.shotsFor += teamScore;\n stats.shotsAgainst += opponentScore;\n\n let resultChar = '';\n if (teamScore > opponentScore) {\n stats.won++;\n stats.points += this.settings.pointsForWin;\n resultChar = 'W';\n } else if (teamScore < opponentScore) {\n stats.lost++;\n stats.points += this.settings.pointsForLoss;\n resultChar = 'L';\n } else {\n stats.drawn++;\n stats.points += this.settings.pointsForDraw;\n resultChar = 'D';\n }\n\n // Format date for description - adjust locale/options as needed\n const matchDateStr = new Date(match.date).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });\n const description = `${matchDateStr}: ${match.homeTeam.name} ${match.result.homeScore} - ${match.result.awayScore} ${match.awayTeam.name}`;\n\n recentMatchDetails.push({\n result: resultChar,\n description: description,\n date: match.date // Keep the original date object if needed elsewhere\n });\n });\n \n // Keep only the details for the last 5 matches\n stats.matches = recentMatchDetails.slice(-5);\n\n // Promotion/relegation positions are set in getLeagueTable after sorting\n return stats;\n }\n\n getLeagueTable() {\n // First, calculate basic stats for all teams\n const teamStats = this.teams.map(team => {\n const stats = this.getTeamStats(team._id);\n return {\n teamId: team._id,\n teamName: team.name,\n ...stats,\n shotDifference: stats.shotsFor - stats.shotsAgainst\n };\n });\n \n // Sort the table\n const sortedTable = teamStats.sort((a, b) => {\n // Sort by points first\n if (b.points !== a.points) {\n return b.points - a.points;\n }\n // Then by shot difference\n if (b.shotDifference !== a.shotDifference) {\n return b.shotDifference - a.shotDifference;\n }\n // Then by shots scored\n if (b.shotsFor !== a.shotsFor) {\n return b.shotsFor - a.shotsFor;\n }\n // Finally by team name\n return a.teamName.localeCompare(b.teamName);\n });\n \n // Now update promotion/relegation positions\n if (this.settings.promotionPositions || this.settings.relegationPositions) {\n sortedTable.forEach((team, index) => {\n const position = index + 1;\n \n if (this.settings.promotionPositions && position <= this.settings.promotionPositions) {\n team.inPromotionPosition = true;\n }\n \n if (this.settings.relegationPositions && position > (sortedTable.length - this.settings.relegationPositions)) {\n team.inRelegationPosition = true;\n }\n });\n }\n \n return {\n leagueData: sortedTable,\n metaData: {\n name: this.name\n }\n };\n }\n\n /*\n * Initialise fixtures for the league using a round-robin algorithm.\n * Each team plays once per match day. Fixtures are scheduled according to the provided scheduling parameters.\n * Teams will play each other `this.settings.timesTeamsPlayOther` times, with home and away fixtures balanced\n * as per the cycles of the round-robin generation (e.g., first cycle A vs B, second cycle B vs A).\n *\n * @param {Date} [startDate] - The start date for the first round of matches. If null, matches will have null dates.\n * @param {Object} [schedulingParams] - Advanced scheduling parameters\n * @param {string} [schedulingParams.schedulingPattern='interval'] - 'interval' or 'dayOfWeek'\n * @param {number} [schedulingParams.intervalNumber=1] - Number of interval units between match days\n * @param {string} [schedulingParams.intervalUnit='weeks'] - 'days' or 'weeks'\n * @param {Array<number>} [schedulingParams.selectedDays] - Array of day numbers (0=Sunday, 1=Monday, etc.) for dayOfWeek pattern\n * @param {number} [schedulingParams.maxMatchesPerDay] - Maximum matches per day (defaults to maxRinksPerSession if not provided)\n * @returns {boolean} - True if fixtures were successfully created, false if there are fewer than 2 teams or if a match fails to be added.\n */\n initialiseFixtures(startDate, schedulingParams = {}) {\n return initialiseFixturesUtil(this, startDate, schedulingParams);\n }\n\n /**\n * Returns the filtered list of matches requiring attention, sorted by priority.\n * @returns {Array}\n */\n getMatchesRequiringAttention() {\n return getMatchesRequiringAttentionUtil(this);\n }\n\n /**\n * Returns a set of match IDs that are in scheduling conflict.\n * @returns {Set<string>}\n */\n getConflictingMatchIds() {\n return getConflictingMatchIdsUtil(this);\n }\n \n /**\n * Convert league to JSON\n * @returns {Object} - JSON representation of the league\n */\n toJSON() {\n return {\n _id: this._id,\n name: this.name,\n teams: this.teams.map(team => team.toJSON ? team.toJSON() : team),\n matches: this.matches.map(match => match.toJSON ? match.toJSON() : match),\n settings: this.settings,\n createdAt: this.createdAt,\n updatedAt: this.updatedAt\n };\n }\n} "],"names":["validateLeague","data","errors","name","push","settings","pointsForWin","pointsForDraw","pointsForLoss","timesTeamsPlayOther","isValid","length","validateTeam","_id","trim","validateMatch","homeTeam","awayTeam","date","Date","isNaN","getTime","result","homeScore","awayScore","generateGUID","hex","Math","floor","random","toString","Array","fill","map","join","Team","constructor","validationResult","Error","createdAt","updatedAt","update","updates","Object","assign","details","toJSON","Match","rink","rinkScores","homeTeamName","awayTeamName","getWinner","isDraw","setRinkScores","getRinkResults","rinkResults","homeWins","awayWins","draws","total","forEach","jsonResult","winner","_findNextValidDate","fromDate","schedulingPattern","intervalNumber","intervalUnit","selectedDays","isFirstRound","currentDay","getDay","includes","i","checkDay","nextDate","setDate","getDate","_getNextSchedulingDate","currentDate","daysToAdd","_assignRinksToMatches","league","matches","maxRinksPerSession","maxRinks","assignedRinks","availableRinks","from","_","randomIndex","selectedRink","splice","_assignMatchDates","allMatches","startDate","maxMatchesPerDay","matchesByRound","matchData","roundKey","sortedRoundKeys","keys","sort","roundMatches","matchIndex","matchesForThisDate","slice","validDate","match","addMatch","initialiseFixtures","schedulingParams","teams","scheduleTeams","BYE_TEAM_MARKER","now","numEffectiveTeams","roundsPerCycle","fixedTeam","initialRotatingTeams","cycle","currentRotatingTeams","roundNum","conceptualPairsForThisRound","opponentForFixed","team1","team2","rotatingListLength","j","teamA_idx","teamB_idx","teamA","teamB","pair","lastTeamInRotatingList","pop","unshift","getConflictingMatchIds","Set","today","setHours","todayTimestamp","futureFixtures","filter","matchDate","matchesByDate","reduce","acc","dateKey","conflictingIds","matchesOnDay","teamCounts","_match$homeTeam","_match$awayTeam","homeTeamId","awayTeamId","conflictingTeams","teamId","rinkCounts","matchesWithRinks","conflictingRinks","_match$homeTeam2","_match$awayTeam2","add","getMatchesRequiringAttention","isArray","getPriority","matchDateObj","matchTimestamp","has","a","b","_a$homeTeam","_b$homeTeam","_a$awayTeam","_b$awayTeam","priorityA","priorityB","homeTeamA","homeTeamB","awayTeamA","awayTeamB","homeCompare","localeCompare","League","fromJSON","jsonData","JSON","parse","error","message","_data$settings","_data$settings2","_data$settings3","_data$settings4","_data$settings5","_data$settings6","_data$settings7","_data$settings8","promotionPositions","relegationPositions","rinkPoints","pointsPerRinkWin","pointsPerRinkDraw","defaultRinks","enabled","team","addTeam","teamInstance","some","t","removeTeam","initialLength","matchInstance","homeTeamExists","awayTeamExists","m","getTeam","find","getMatch","matchId","getTeamMatches","getTeamStats","stats","teamName","played","won","drawn","lost","shotsFor","shotsAgainst","points","inPromotionPosition","inRelegationPosition","playedMatches","recentMatchDetails","console","warn","isHome","teamScore","opponentScore","resultChar","matchDateStr","toLocaleDateString","undefined","year","month","day","description","getLeagueTable","teamStats","shotDifference","sortedTable","index","position","leagueData","metaData","initialiseFixturesUtil","get