// // MatchScheduler.swift // PadelClub // // Created by Razmig Sarkissian on 08/04/2024. // import Foundation import LeStorage import SwiftUI @Observable class MatchScheduler : ModelObject, Storable { static func resourceName() -> String { return "match-scheduler" } static func tokenExemptedMethods() -> [HTTPMethod] { return [] } 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 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) { 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 } 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" } 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 } func tournamentObject() -> Tournament? { return Store.main.findById(tournament) } @discardableResult func updateGroupStageSchedule(tournament: Tournament) -> Date { let computedGroupStageChunkCount = groupStageChunkCount ?? 1 let groupStages: [GroupStage] = tournament.groupStages() let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount let matches = groupStages.flatMap { $0._matches() } matches.forEach({ $0.removeCourt() $0.startDate = nil $0.confirmed = false }) var lastDate : Date = 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 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 DataStore.shared.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 DataStore.shared.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 // Use zip and flatMap to flatten matches in the desired order 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..= numberOfCourtsAvailablePerRotation - courtsUnavailable { return false } else { return teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true } }) { let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index) slots.append(timeMatch) teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds()) rotationMatches.removeAll(where: { $0.id == first.id }) availableMatchs.removeAll(where: { $0.id == first.id }) if let index = first.groupStageObject?.index { groupLastRotation[index] = rotationIndex } } else { freeCourtPerRotation[rotationIndex]!.append(courtIndex) } } rotationIndex += 1 } var organizedSlots = [GroupStageTimeMatch]() for i in 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(roundObject.roundTitle(), match.matchTitle()) if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate { print("can't start \(targetedStartDate) earlier than \(roundStartDate)") if targetedStartDate == minimumTargetedEndDate { minimumTargetedEndDate = roundStartDate } else { minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) } return false } let previousMatches = roundObject.precedentMatches(ofMatch: match) if previousMatches.isEmpty { return true } let previousMatchSlots = slots.filter({ slot in previousMatches.map { $0.id }.contains(slot.matchID) }) if previousMatchSlots.isEmpty { if previousMatches.filter({ $0.disabled == false }).allSatisfy({ $0.startDate != nil }) { return true } return false } if previousMatches.filter({ $0.disabled == false }).count > previousMatchSlots.count { if previousMatches.filter({ $0.disabled == false }).anySatisfy({ $0.startDate != nil }) { return true } return false } var includeBreakTime = false if accountLoserBracketBreakTime && roundObject.isLoserBracket() { includeBreakTime = true } if accountUpperBracketBreakTime && roundObject.isLoserBracket() == false { includeBreakTime = true } let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex }) guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else { return previousMatchIsInPreviousRotation } if targetedStartDate >= minimumPossibleEndDate { if rotationDifferenceIsImportant { return previousMatchIsInPreviousRotation } else { return true } } else { if targetedStartDate == minimumTargetedEndDate { minimumTargetedEndDate = minimumPossibleEndDate } else { minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) } 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 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 == false { rotationIndex += 1 } var freeCourtPerRotation = [Int: [Int]]() let availableCourt = numberOfCourtsAvailablePerRotation var courts = initialCourts ?? (0.. 0 while availableMatchs.count > 0 && issueFound == false { 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.count > 0 { print("scenario where we are waiting for a breaktime to be over without any match to play in between or a free court was available and we need to recheck breaktime left on it") 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 w break", differenceWithBreak) print("difference w/o 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: { index in freeCourtPreviousRotation.contains(index) }) freeCourtPerRotation[rotationIndex] = courts courts = freeCourtPreviousRotation rotationStartDate = rotationStartDate.addingTimeInterval(-difference) } } } else if let first = availableMatchs.first { let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration) let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) if courtsUnavailable == numberOfCourtsAvailablePerRotation { print("issue") issueFound = true } } dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) rotationIndex += 1 } var organizedSlots = [TimeMatch]() for i in 0..= availableCourts - courtsUnavailable { return false } let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0 let roundMatchesCount = roundObject.playedMatches().count if shouldHandleUpperRoundSlice { print("shouldHandleUpperRoundSlice \(roundMatchesCount)") if roundObject.parent == nil && roundMatchesCount > courts.count { print("roundMatchesCount \(roundMatchesCount) > \(courts.count)") if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { print("return false, \(currentRotationSameRoundMatches) >= \(min(roundMatchesCount / 2, courts.count))") return false } } } //if all is ok, we do a final check to see if the first let indexInRound = match.indexInRound() print("Upper Round, index > 0, first Match of round \(indexInRound) and more than one court available; looking for next match (same round) \(indexInRound + 1)") if roundObject.parent == nil && roundObject.index > 0, indexInRound == 0, let nextMatch = match.next() { guard courtPosition < courts.count - 1, courts.count > 1 else { print("next match and this match can not be played at the same time, returning false") return false } if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { print("next match and this match can be played, returning true") return true } } //not adding a last match of a 4-match round (final not included obviously) print("\(currentRotationSameRoundMatches) modulo \(currentRotationSameRoundMatches%2) same round match is even, index of round is not 0 and upper bracket. If it's not the last court available \(courtIndex) == \(courts.count - 1)") if roundMatchesCount <= 4 && currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.parent == nil && ((courts.count > 1 && courtPosition >= courts.count - 1) || courts.count == 1 && availableCourts > 1) { print("we return false") return false } return canBePlayed }) { print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate) if first.roundObject!.parent == nil { if let roundIndex = matchPerRound[first.roundObject!.id] { matchPerRound[first.roundObject!.id] = roundIndex + 1 } else { matchPerRound[first.roundObject!.id] = 1 } } let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: first.matchFormat.breakTime.breakTime) slots.append(timeMatch) availableMatchs.removeAll(where: { $0.id == first.id }) } else { freeCourtPerRotation[rotationIndex]!.append(courtIndex) } } if freeCourtPerRotation[rotationIndex]!.count == availableCourts { print("no match found to be put in this rotation, check if we can put anything to another date") freeCourtPerRotation[rotationIndex] = [] let courtsUsed = getNextEarliestAvailableDate(from: slots) var freeCourts: [Int] = [] if courtsUsed.isEmpty { freeCourts = (0.. Bool { let upperRounds: [Round] = tournament.rounds() let allMatches: [Match] = tournament.allMatches() var rounds = [Round]() if shouldEndRoundBeforeStartingNext { rounds = upperRounds.flatMap { [$0] + $0.loserRoundsAndChildren() } } else { rounds = 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 DataStore.shared.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 0 } let groupedBy = Dictionary(grouping: courtsUnavailability, by: { $0.courtIndex }) let courts = groupedBy.keys return courts.filter { courtUnavailable(courtIndex: $0, from: startDate, to: endDate, source: courtsUnavailability) }.count } 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 { let lastDate = updateGroupStageSchedule(tournament: tournament) 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) } }