// // MatchScheduler.swift // PadelClub // // Created by Razmig Sarkissian on 08/04/2024. // import Foundation import LeStorage import SwiftUI @Observable final class MatchScheduler : ModelObject, Storable { static func resourceName() -> String { return "match-scheduler" } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } static func filterByStoreIdentifier() -> Bool { return false } static var relationshipNames: [String] = [] private(set) var id: String = Store.randomId() var tournament: String var timeDifferenceLimit: Int var loserBracketRotationDifference: Int var upperBracketRotationDifference: Int var accountUpperBracketBreakTime: Bool var accountLoserBracketBreakTime: Bool var randomizeCourts: Bool var rotationDifferenceIsImportant: Bool var shouldHandleUpperRoundSlice: Bool var shouldEndRoundBeforeStartingNext: Bool var groupStageChunkCount: Int? var overrideCourtsUnavailability: Bool = false var shouldTryToFillUpCourtsAvailable: Bool = false init(tournament: String, timeDifferenceLimit: Int = 5, loserBracketRotationDifference: Int = 0, upperBracketRotationDifference: Int = 1, accountUpperBracketBreakTime: Bool = true, accountLoserBracketBreakTime: Bool = false, randomizeCourts: Bool = true, rotationDifferenceIsImportant: Bool = false, shouldHandleUpperRoundSlice: Bool = true, shouldEndRoundBeforeStartingNext: Bool = true, groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) { self.tournament = tournament self.timeDifferenceLimit = timeDifferenceLimit self.loserBracketRotationDifference = loserBracketRotationDifference self.upperBracketRotationDifference = upperBracketRotationDifference self.accountUpperBracketBreakTime = accountUpperBracketBreakTime self.accountLoserBracketBreakTime = accountLoserBracketBreakTime self.randomizeCourts = randomizeCourts self.rotationDifferenceIsImportant = rotationDifferenceIsImportant self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext self.groupStageChunkCount = groupStageChunkCount self.overrideCourtsUnavailability = overrideCourtsUnavailability self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable } enum CodingKeys: String, CodingKey { case _id = "id" case _tournament = "tournament" case _timeDifferenceLimit = "timeDifferenceLimit" case _loserBracketRotationDifference = "loserBracketRotationDifference" case _upperBracketRotationDifference = "upperBracketRotationDifference" case _accountUpperBracketBreakTime = "accountUpperBracketBreakTime" case _accountLoserBracketBreakTime = "accountLoserBracketBreakTime" case _randomizeCourts = "randomizeCourts" case _rotationDifferenceIsImportant = "rotationDifferenceIsImportant" case _shouldHandleUpperRoundSlice = "shouldHandleUpperRoundSlice" case _shouldEndRoundBeforeStartingNext = "shouldEndRoundBeforeStartingNext" case _groupStageChunkCount = "groupStageChunkCount" case _overrideCourtsUnavailability = "overrideCourtsUnavailability" case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable" } var courtsUnavailability: [DateInterval]? { guard let event = tournamentObject()?.eventObject() else { return nil } return event.courtsUnavailability + (overrideCourtsUnavailability ? [] : event.tournamentsCourtsUsed(exluding: tournament)) } var additionalEstimationDuration : Int { return tournamentObject()?.additionalEstimationDuration ?? 0 } var tournamentStore: TournamentStore { return TournamentStore.instance(tournamentId: self.tournament) } func tournamentObject() -> Tournament? { return Store.main.findById(tournament) } @discardableResult func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date { let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue() var groupStages: [GroupStage] = tournament.groupStages(atStep: step) if let specificGroupStage { groupStages = [specificGroupStage] } let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount let matches = groupStages.flatMap { $0._matches() } matches.forEach({ $0.removeCourt() $0.startDate = nil $0.confirmed = false }) var lastDate : Date = startDate ?? tournament.startDate let times = Set(groupStages.compactMap { $0.startDate }).sorted() if let first = times.first { if first.isEarlierThan(tournament.startDate) { tournament.startDate = first do { try DataStore.shared.tournaments.addOrUpdate(instance: tournament) } catch { Logger.error(error) } } } times.forEach({ time in if lastDate.isEarlierThan(time) { lastDate = time } let groups = groupStages.filter({ $0.startDate == time }) let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) dispatch.timedMatches.forEach { matchSchedule in if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 if let startDate = match.groupStageObject?.startDate { let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) match.startDate = matchStartDate lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) } match.setCourt(matchSchedule.courtIndex) } } }) groupStages.filter({ $0.startDate == nil || times.contains($0.startDate!) == false }).chunked(into: computedGroupStageChunkCount).forEach { groups in groups.forEach({ $0.startDate = lastDate }) do { try self.tournamentStore.groupStages.addOrUpdate(contentOfs: groups) } catch { Logger.error(error) } let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) dispatch.timedMatches.forEach { matchSchedule in if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 if let startDate = match.groupStageObject?.startDate { let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) match.startDate = matchStartDate lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) } match.setCourt(matchSchedule.courtIndex) } } } do { try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) } catch { Logger.error(error) } return lastDate } func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher { let _groupStages = groupStages // Get the maximum count of matches in any group let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 // Flatten matches in a round-robin order by cycling through each group let flattenedMatches = (0.. 0 { rotationMatches = rotationMatches.sorted(by: { if counts[$0.groupStageObject!.index] ?? 0 == counts[$1.groupStageObject!.index] ?? 0 { return $0.groupStageObject!.index < $1.groupStageObject!.index } else { return counts[$0.groupStageObject!.index] ?? 0 < counts[$1.groupStageObject!.index] ?? 0 } }) } (0.. Int { if loserBracket { return loserBracketRotationDifference } else { return upperBracketRotationDifference } } func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool { print("Evaluating match: \(match.roundAndMatchTitle()) in round: \(roundObject.roundTitle()) with index: \(match.index)") if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate { print("Cannot start at \(targetedStartDate), earlier than round start date \(roundStartDate)") if targetedStartDate == minimumTargetedEndDate { print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)") minimumTargetedEndDate = roundStartDate } else { print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)") minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) } print("Returning false: Match cannot start earlier than the round start date.") return false } let previousMatches = roundObject.precedentMatches(ofMatch: match) if previousMatches.isEmpty { print("No ancestors matches for this match, returning true. (eg beginning of tournament 1st bracket") return true } let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) } if previousMatchSlots.isEmpty { if previousMatches.filter({ !$0.disabled }).allSatisfy({ $0.startDate != nil }) { print("All previous matches have start dates, returning true.") return true } print("Some previous matches are pending, returning false.") return false } if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count { if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) { print("Some previous matches started, returning true.") return true } print("Not enough previous matches have started, returning false.") return false } var includeBreakTime = false if accountLoserBracketBreakTime && roundObject.isLoserBracket() { includeBreakTime = true print("Including break time for loser bracket.") } if accountUpperBracketBreakTime && !roundObject.isLoserBracket() { includeBreakTime = true print("Including break time for upper bracket.") } let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy { $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex } if previousMatchIsInPreviousRotation { print("All previous matches are from earlier rotations, returning true.") } else { print("Some previous matches are from the current rotation.") } guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else { print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).") return previousMatchIsInPreviousRotation } if targetedStartDate >= minimumPossibleEndDate { if rotationDifferenceIsImportant { print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).") return previousMatchIsInPreviousRotation } else { print("Targeted start date is after the minimum possible end date, returning true.") return true } } else { if targetedStartDate == minimumTargetedEndDate { print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)") minimumTargetedEndDate = minimumPossibleEndDate } else { print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)") minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) } print("Targeted start date is before the minimum possible end date, returning false.") return false } } func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? { slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min() } func getNextEarliestAvailableDate(from slots: [TimeMatch]) -> [(Int, Date)] { let byCourt = Dictionary(grouping: slots, by: { $0.courtIndex }) return (byCourt.keys.flatMap { courtIndex in let matchesByCourt = byCourt[courtIndex]?.sorted(by: \.startDate) let lastMatch = matchesByCourt?.last var results = [(Int, Date)]() if let courtFreeDate = lastMatch?.estimatedEndDate(includeBreakTime: false) { results.append((courtIndex, courtFreeDate)) } return results } ) } func getAvailableCourts(from matches: [Match]) -> [(Int, Date)] { let validMatches = matches.filter({ $0.courtIndex != nil && $0.startDate != nil }) let byCourt = Dictionary(grouping: validMatches, by: { $0.courtIndex! }) return (byCourt.keys.flatMap { court in let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!) let lastMatch = matchesByCourt?.last var results = [(Int, Date)]() if let courtFreeDate = lastMatch?.estimatedEndDate(additionalEstimationDuration) { results.append((court, courtFreeDate)) } return results } ) } func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { var slots = [TimeMatch]() var _startDate: Date? var rotationIndex = 0 var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) let courtsUnavailability = courtsUnavailability var issueFound: Bool = false // Log start of the function print("Starting roundDispatcher with \(availableMatchs.count) matches and \(numberOfCourtsAvailablePerRotation) courts available") flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in if _startDate == nil { _startDate = match.startDate } else if match.startDate! > _startDate! { _startDate = match.startDate rotationIndex += 1 } let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime) slots.append(timeMatch) } if !slots.isEmpty { rotationIndex += 1 } var freeCourtPerRotation = [Int: [Int]]() let availableCourt = numberOfCourtsAvailablePerRotation var courts = initialCourts ?? (0.. 0 while !availableMatchs.isEmpty && !issueFound && rotationIndex < 100 { freeCourtPerRotation[rotationIndex] = [] let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 }) var rotationStartDate: Date = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate if shouldStartAtDispatcherDate { rotationStartDate = dispatcherStartDate shouldStartAtDispatcherDate = false } else { courts = rotationIndex == 0 ? courts : (0.. 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty { print("Handling break time conflicts or waiting for free courts") let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) } let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime) let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak } if let previousEndDate, let previousEndDateNoBreak { let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate) let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak) print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)") let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60) var difference = differenceWithBreak if differenceWithBreak <= 0 { difference = differenceWithoutBreak } else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds { difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak) } if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate { courts.removeAll(where: { freeCourtPreviousRotation.contains($0) }) freeCourtPerRotation[rotationIndex] = courts courts = freeCourtPreviousRotation rotationStartDate = rotationStartDate.addingTimeInterval(-difference) } } } else if let firstMatch = availableMatchs.first { let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration) let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) if courtsUnavailable.count == numberOfCourtsAvailablePerRotation { print("Issue: All courts unavailable in this rotation") issueFound = true } else { courts = Array(Set(courts).subtracting(Set(courtsUnavailable))) } } // Dispatch courts and schedule matches dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) rotationIndex += 1 } // Organize matches in slots var organizedSlots = [TimeMatch]() for i in 0.. courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).") return false } } let indexInRound = match.indexInRound() if roundObject.parent == nil && roundObject.index > 0 && indexInRound == 0, let nextMatch = match.next() { if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).") return true } else { print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).") return false } } print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).") return canBePlayed }) { print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)") matchPerRound[firstMatch.roundObject!.id, default: 0] += 1 let timeMatch = TimeMatch( matchID: firstMatch.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime ) slots.append(timeMatch) availableMatchs.removeAll(where: { $0.id == firstMatch.id }) } else { print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.") freeCourtPerRotation[rotationIndex]?.append(courtIndex) } } if freeCourtPerRotation[rotationIndex]?.count == availableCourts { print("All courts in rotation \(rotationIndex) are free") } } @discardableResult func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool { let upperRounds: [Round] = tournament.rounds() let allMatches: [Match] = tournament.allMatches() var rounds = [Round]() if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() { rounds.append(groupStageLoserBracketRound) } if shouldEndRoundBeforeStartingNext { rounds.append(contentsOf: upperRounds.flatMap { [$0] + $0.loserRoundsAndChildren() }) } else { rounds.append(contentsOf: upperRounds.map { $0 } + upperRounds.flatMap { $0.loserRoundsAndChildren() }) } let flattenedMatches = rounds.flatMap { round in round._matches().filter({ $0.disabled == false }).sorted(by: \.index) } flattenedMatches.forEach({ if (roundId == nil && matchId == nil) || $0.startDate?.isEarlierThan(startDate) == false { $0.startDate = nil $0.removeCourt() $0.confirmed = false } }) // if let roundId { // if let round : Round = Store.main.findById(roundId) { // let matches = round._matches().filter({ $0.disabled == false }).sorted(by: \.index) // round.resetFromRoundAllMatchesStartDate() // flattenedMatches = matches + flattenedMatches // } // // } else if let matchId { // if let match : Match = Store.main.findById(matchId) { // if let round = match.roundObject { // round.resetFromRoundAllMatchesStartDate(from: match) // } // flattenedMatches = [match] + flattenedMatches // } // } if let roundId, let matchId { //todo if let index = flattenedMatches.firstIndex(where: { $0.round == roundId && $0.id == matchId }) { flattenedMatches[index...].forEach { $0.startDate = nil $0.removeCourt() $0.confirmed = false } } } else if let roundId { //todo if let index = flattenedMatches.firstIndex(where: { $0.round == roundId }) { flattenedMatches[index...].forEach { $0.startDate = nil $0.removeCourt() $0.confirmed = false } } } let matches: [Match] = allMatches.filter { $0.startDate?.isEarlierThan(startDate) == true && $0.startDate?.dayInt == startDate.dayInt } let usedCourts = getAvailableCourts(from: matches) let initialCourts: [Int] = usedCourts.filter { (court, availableDate) in availableDate <= startDate }.sorted(by: \.1).compactMap { $0.0 } let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts print("initial available courts at beginning: \(courts ?? [])") let roundDispatch = self.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts) roundDispatch.timedMatches.forEach { matchSchedule in if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) { match.startDate = matchSchedule.startDate match.setCourt(matchSchedule.courtIndex) } } do { try self.tournamentStore.matches.addOrUpdate(contentOfs: allMatches) } catch { Logger.error(error) } return roundDispatch.issueFound } func courtsUnavailable(startDate: Date, duration: Int, courtsUnavailability: [DateInterval]?) -> [Int] { let endDate = startDate.addingTimeInterval(Double(duration) * 60) guard let courtsUnavailability else { return [] } let groupedBy = Dictionary(grouping: courtsUnavailability, by: { $0.courtIndex }) let courts = groupedBy.keys return courts.filter { courtUnavailable(courtIndex: $0, from: startDate, to: endDate, source: courtsUnavailability) } } func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date, source: [DateInterval]) -> Bool { let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex }) return courtLockedSchedule.anySatisfy({ dateInterval in let range = startDate.. Bool { var lastDate = tournament.startDate if tournament.groupStageCount > 0 { lastDate = updateGroupStageSchedule(tournament: tournament) } if tournament.groupStages(atStep: 1).isEmpty == false { lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate) } return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) } } struct GroupStageTimeMatch { let matchID: String let rotationIndex: Int var courtIndex: Int let groupIndex: Int } struct TimeMatch { let matchID: String let rotationIndex: Int var courtIndex: Int var startDate: Date var durationLeft: Int //in minutes var minimumBreakTime: Int //in minutes func estimatedEndDate(includeBreakTime: Bool) -> Date { let minutesToAdd = Double(durationLeft + (includeBreakTime ? minimumBreakTime : 0)) return startDate.addingTimeInterval(minutesToAdd * 60.0) } } struct GroupStageMatchDispatcher { let timedMatches: [GroupStageTimeMatch] let freeCourtPerRotation: [Int: [Int]] let rotationCount: Int let groupLastRotation: [Int: Int] } struct MatchDispatcher { let timedMatches: [TimeMatch] let freeCourtPerRotation: [Int: [Int]] let rotationCount: Int let issueFound: Bool } extension Match { func teamIds() -> [String] { return teams().map { $0.id } } func containsTeamId(_ id: String) -> Bool { return teamIds().contains(id) } }