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