UNPKG

@lovebowls/leaguejs

Version:

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

486 lines (424 loc) 20.9 kB
import { Match } from '../models/Match.js'; /** * Find the next valid date based on scheduling pattern */ function _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 */ function _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; } /** * Private helper method to assign rinks to matches * @param {object} league - The league instance * @param {Array} matches - Array of match data * @returns {Array} - Array of assigned rink numbers (or null if no rinks assigned) */ function _assignRinksToMatches(league, matches) { if (!league.settings.maxRinksPerSession || league.settings.maxRinksPerSession < 1) { // No rink assignment if maxRinksPerSession is not set or invalid return matches.map(() => null); } const maxRinks = league.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; } /** * Private helper method to assign dates to matches based on scheduling parameters */ function _assignMatchDates(league, 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 = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound); // Assign rinks for matches on this date const assignedRinks = _assignRinksToMatches(league, 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 (!league.addMatch(match)) { return false; // If addMatch fails, abort } } matchIndex += maxMatchesPerDay; isFirstRound = false; // Move to next date for remaining matches if (matchIndex < roundMatches.length) { currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } // Set current date for next round if (matchIndex >= roundMatches.length) { currentDate = _getNextSchedulingDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } else { // All matches in this round can fit on one day const validDate = _findNextValidDate(currentDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, isFirstRound); // Assign rinks for matches on this date const assignedRinks = _assignRinksToMatches(league, 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 (!league.addMatch(match)) { return false; // If addMatch fails, abort } } isFirstRound = false; // Move to next date for next round currentDate = _getNextSchedulingDate(validDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays); } } return true; } /** * 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 {object} league - The league instance * @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. */ export function initialiseFixtures(league, startDate, schedulingParams = {}) { league.matches = []; // Clear any existing matches if (league.teams.length < 2) { return false; // Not enough teams to create fixtures } // Set default scheduling parameters const { schedulingPattern = 'interval', intervalNumber = 1, intervalUnit = 'weeks', selectedDays = [], maxMatchesPerDay = league.settings.maxRinksPerSession || null } = schedulingParams; // Prepare team list for scheduling. Add a dummy "BYE" team if the number of teams is odd. let scheduleTeams = [...league.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 < league.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) { // For balanced home/away games, the fixed team should alternate home/away status in each round if (roundNum % 2 === 0) { conceptualPairsForThisRound.push({ team1: opponentForFixed, team2: fixedTeam }); // fixed team is away } else { conceptualPairsForThisRound.push({ team1: fixedTeam, team2: opponentForFixed }); // fixed team is home } } // 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) { _assignMatchDates(league, allMatches, startDate, schedulingPattern, intervalNumber, intervalUnit, selectedDays, maxMatchesPerDay); } else { // No start date provided - create matches without dates but with rinks if configured const assignedRinks = _assignRinksToMatches(league, 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 (!league.addMatch(match)) { return false; // If addMatch fails (e.g., duplicate _id), abort. } } } return true; } /** * Returns a set of match IDs that are in scheduling conflict. * @param {object} league - The league instance * @returns {Set<string>} */ export function getConflictingMatchIds(league) { if (!league.matches) return new Set(); const today = new Date(); today.setHours(0, 0, 0, 0); const todayTimestamp = today.getTime(); const futureFixtures = league.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 => { const homeTeamId = match.homeTeam?._id; const awayTeamId = 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 => { const homeTeamId = match.homeTeam?._id; const awayTeamId = match.awayTeam?._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; } /** * Returns the filtered list of matches requiring attention, sorted by priority. * @param {object} league - The league instance * @returns {Array} */ export function getMatchesRequiringAttention(league) { if (!league.matches || !Array.isArray(league.matches)) return []; const today = new Date(); today.setHours(0, 0, 0, 0); const todayTimestamp = today.getTime(); // Scheduling conflict detection const conflictingIds = getConflictingMatchIds(league); 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 league.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) => { const priorityA = getPriority(a); const priorityB = getPriority(b); if (priorityA !== priorityB) return priorityA - priorityB; const homeTeamA = a.homeTeam?.name || ''; const homeTeamB = b.homeTeam?.name || ''; const awayTeamA = a.awayTeam?.name || ''; const awayTeamB = b.awayTeam?.name || ''; const homeCompare = homeTeamA.localeCompare(homeTeamB); if (homeCompare !== 0) return homeCompare; return awayTeamA.localeCompare(awayTeamB); }); }