UNPKG

@lovebowls/leagueelements

Version:

League Elements package for LoveBowls

1,299 lines (1,186 loc) 988 kB
var LeagueAdminElement = (function () { '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 {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 {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.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, 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 {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; 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 }; if ((_data$settings7 = data.settings) !== null && _data$settings7 !== void 0 && _data$settings7.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 (optional) * @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 = 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 for (const matchData of allMatches) { const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: null }); 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); // Create matches for this date for (const matchData of matchesForThisDate) { const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: new Date(validDate) }); 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); // Create matches for this date for (const matchData of roundMatches) { const match = new Match({ homeTeam: matchData.homeTeam, awayTeam: matchData.awayTeam, date: new Date(validDate) }); 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; } /** * 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; 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); 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); } }); } } 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 }; } } var index = /*#__PURE__*/Object.freeze({ __proto__: null, League: League, Match: Match, Team: Team, validateLeague: validateLeague, validateMatch: validateMatch, validateTeam: validateTeam }); var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function getDefaultExportFromCjs (x) { return x && x.__esModule && Object.prototype.hasOwnProperty.call(x, 'default') ? x['default'] : x; } var sweetalert2_all$2 = {exports: {}}; /*! * sweetalert2 v11.22.0 * Released under the MIT License. */ var sweetalert2_all$1 = sweetalert2_all$2.exports; var hasRequiredSweetalert2_all; function requireSweetalert2_all () { if (hasRequiredSweetalert2_all) return sweetalert2_all$2.exports; hasRequiredSweetalert2_all = 1; (function (module, exports) { (function (global, factory) { module.exports = factory() ; })(sweetalert2_all$1, (function () { function _assertClassBrand(e, t, n) { if ("function" == typeof e ? e === t : e.has(t)) return arguments.length < 3 ? t : n; throw new TypeError("Private element is not present on this object"); } function _checkPrivateRedeclaration(e, t) { if (t.has(e)) throw new TypeError("Cannot initialize the same private elements twice on an object"); } function _classPrivateFieldGet2(s, a) { return s.get(_assertClassBrand(s, a)); } function _classPrivateFieldInitSpec(e, t, a) { _checkPrivateRedeclaration(e, t), t.set(e, a); } function _classPrivateFieldSet2(s, a, r) { return s.set(_assertClassBrand(s, a), r), r; } const RESTORE_FOCUS_TIMEOUT = 100; /** @type {GlobalState} */ const globalState = {}; const focusPreviousActiveElement = () => { if (globalState.previousActiveElement instanceof HTMLElement) { globalState.previousActiveElement.focus(); globalState.previousActiveElement = null; } else if (document.body) { document.body.focus(); } }; /** * Restore previous active (focused) element * * @param {boolean} returnFocus * @returns {Promise<void>} */ const restoreActiveElement = returnFocus => { return new Promise(resolve => { if (!returnFocus) { return resolve(); } const x = window.scrollX; const y = window.scrollY; globalState.restoreFocusTimeout = setTimeout(() => { focusPreviousActiveElement(); resolve(); }, RESTORE_FOCUS_TIMEOUT); // issues/900 window.scrollTo(x, y); }); }; const swalPrefix = 'swal2-'; /** * @typedef {Record<SwalClass, string>} SwalClasses */ /** * @typedef {'success' | 'warning' | 'info' | 'question' | 'error'} SwalIcon * @typedef {Record<SwalIcon, string>} SwalIcons */ /** @type {SwalClass[]} */ const classNames = ['container', 'shown', 'height-auto', 'iosfix', 'popup', 'modal', 'no-backdrop', 'no-transition', 'toast', 'toast-shown', 'show', 'hide', 'close', 'title', 'html-container', 'actions', 'confirm', 'deny', 'cancel', 'footer', 'icon', 'icon-content', 'image', 'input', 'file', 'range', 'select', 'radio', 'checkbox', 'label', 'textarea', 'inputerror', 'input-label', 'validation-message', 'progress-steps', 'active-progress-step', 'progress-step', 'progress-step-line', 'loader', 'loading', 'styled', 'top', 'top-start', 'top-end', 'top-left', 'top-right', 'center', 'center-start', 'center-end', 'center-left', 'center-right', 'bottom', 'bottom-start', 'bottom-end', 'bottom-left', 'bottom-right', 'grow-row', 'grow-column', 'grow-fullscreen', 'rtl', 'timer-progress-bar', 'timer-progress-bar-container', 'scrollbar-measure', 'icon-success', 'icon-warning', 'icon-info', 'icon-question', 'icon-error', 'draggable', 'dragging']; const swalClasses = classNames.reduce((acc, className) => { acc[className] = swalPrefix + className; return acc; }, /** @type {SwalClasses} */{}); /** @type {SwalIcon[]} */ const icons = ['success', 'warning', 'info', 'question', 'error']; const iconTypes = icons.reduce((acc, icon) => { acc[icon] = swalPrefix + icon; return acc; }, /** @type {SwalIcons} */{}); const consolePrefix = 'SweetAlert2:'; /** * Capitalize the first letter of a string * * @param {string} str * @returns {string} */ const capitalizeFirstLetter = str => str.charAt(0).toUpperCase() + str.slice(1); /** * Standardize console warnings * * @param {string | string[]} message */ const warn = message => { console.warn(`${consolePrefix} ${typeof message === 'object' ? message.join(' ') : message}`); }; /** * Standardize console errors * * @param {string} message */ const error = message => { console.error(`${consolePrefix} ${message}`); }; /** * Private global state for `warnOnce` * * @type {string[]} * @private */ const previousWarnOnceMessages = []; /** * Show a console warning, but only if it hasn't already been shown * * @param {string} message */ const warnOnce = message => { if (!previousWarnOnceMessages.includes(message)) { previousWarnOnceMessages.push(message); warn(message); } }; /** * Show a one-time console warning about deprecated params/methods * * @param {string} deprecatedParam * @param {string?} useInstead */ const warnAboutDeprecation = (deprecatedParam, useInstead = null) => { warnOnce(`"${deprecatedParam}" is deprecated and will be removed in the next major release.${useInstead ? ` Use "${useInstead}" instead.` : ''}`); }; /** * If `arg` is a function, call it (with no arguments or context) and return the result. * Otherwise, just pass the value through * * @param {Function | any} arg * @returns {any} */ const callIfFunction = arg => typeof arg === 'function' ? arg() : arg; /** * @param {any} arg * @returns {boolean} */ const hasToPromiseFn = arg => arg && typeof arg.toPromise === 'function'; /** * @param {any} arg * @returns {Promise<any>} */ const asPromise = arg => hasToPromiseFn(arg) ? arg.toPromise() : Promise.resolve(arg); /** * @param {any} arg * @returns {boolean} */ const isPromise = arg => arg && Promise.resolve(arg) === arg; /** * Gets the popup container which contains the backdrop and the popup itself. * * @returns {HTMLElement | null} */ const getContainer = () => document.body.querySelector(`.${swalClasses.container}`); /** * @param {string} selectorString * @returns {HTMLElement | null} */ const elementBySelector = selectorString => { const container = getContainer(); return container ? container.querySelector(selectorString) : null; }; /** * @param {string} className * @returns {HTMLElement | null} */ const elementByClass = className => { return elementBySelector(`.${className}`); }; /** * @returns {HTMLElement | null} */ const getPopup = () => elementByClass(swalClasses.popup); /** * @returns {HTMLElement | null} */ const getIcon = () => elementByClass(swalClasses.icon); /** * @returns {HTMLElement | null} */ const getIconContent = () => elementByClass(swalClasses['icon-content']); /** * @returns {HTMLElement | null} */ const getTitle = () => elementByClass(swalClasses.title); /** * @returns {HTMLElement | null} */ const getHtmlContainer = () => elementByClass(swalClasses['html-container']); /** * @returns {HTMLElement | null} */ const getImage = () => elementByClass(swalClasses.image); /** * @returns {HTMLElement | null} */ const getProgressSteps = () => elementByClass(swalClasses['progress-steps']); /** * @returns {HTMLElement | null} */ const getValidationMessage = () => elementByClass(swalClasses['validation-message']); /** * @returns {HTMLButtonElement | null} */ const getConfirmButton = () => (/** @type {HTMLButtonElement} */elementBySelector(`.${swalClasses.actions} .${swalClasses.confirm}`)); /** * @returns {HTMLButtonElement | null} */ const getCancelButton = () => (/** @type {HTMLButtonElement} */elementBySelector(`.${swalClasses.actions} .${swalClasses.cancel}`)); /** * @returns {HTMLButtonElement | null} */ const getDenyButton = () => (/** @type {HTMLButtonElement} */elementBySelector(`.${swalClasses.actions} .${swalClasses.deny}`)); /** * @returns {HTMLElement | null} */ const getInputLabel = () => elementByClass(swalClasses['input-label']); /** * @returns {HTMLElement | null} */ const getLoader = () => elementBySelector(`.${swalClasses.loader}`); /** * @returns {HTMLElement | null} */ const getActions = () => elementByClass(swalClasses.actions); /** * @returns {HTMLElement | null} */ const getFooter = () => elementByClass(swalClasses.footer); /** * @returns {HTMLElement | null} */ const getTimerProgressBar = () => elementByClass(swalClasses['timer-progress-bar']); /** * @return