From c025559b5e42ca7044fb8a7029119a3a274da40e Mon Sep 17 00:00:00 2001 From: Raz Date: Fri, 11 Oct 2024 11:11:33 +0200 Subject: [PATCH] fix scheduler and add court pickup --- PadelClub.xcodeproj/project.pbxproj | 8 ++ PadelClub/Data/MatchScheduler.swift | 107 +++++++++++++----- PadelClub/Data/Tournament.swift | 4 + .../Components/MultiCourtPickerView.swift | 38 +++++++ .../Views/Planning/PlanningSettingsView.swift | 56 ++++++--- 5 files changed, 169 insertions(+), 44 deletions(-) create mode 100644 PadelClub/Views/Planning/Components/MultiCourtPickerView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index d860ba4..9f7a7bb 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -81,6 +81,9 @@ FF1162872BD004AD000C4809 /* EditingTeamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1162862BD004AD000C4809 /* EditingTeamView.swift */; }; FF11628A2BD05247000C4809 /* DateUpdateManagerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */; }; FF11628C2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF11628B2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift */; }; + FF17CA492CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; }; + FF17CA4A2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; }; + FF17CA4B2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */; }; FF1CBC1B2BB53D1F0036DAAB /* FederalTournament.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */; }; FF1CBC1D2BB53DC10036DAAB /* Calendar+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */; }; FF1CBC1F2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */; }; @@ -977,6 +980,7 @@ FF1162862BD004AD000C4809 /* EditingTeamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditingTeamView.swift; sourceTree = ""; }; FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateUpdateManagerView.swift; sourceTree = ""; }; FF11628B2BD05267000C4809 /* LoserRoundStepScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundStepScheduleEditorView.swift; sourceTree = ""; }; + FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiCourtPickerView.swift; sourceTree = ""; }; FF1CBC182BB53D1F0036DAAB /* FederalTournament.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournament.swift; sourceTree = ""; }; FF1CBC1C2BB53DC10036DAAB /* Calendar+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Calendar+Extensions.swift"; sourceTree = ""; }; FF1CBC1E2BB53E0C0036DAAB /* FederalTournamentSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FederalTournamentSearchScope.swift; sourceTree = ""; }; @@ -1514,6 +1518,7 @@ isa = PBXGroup; children = ( FF1162892BD05247000C4809 /* DateUpdateManagerView.swift */, + FF17CA482CB915A1003C7323 /* MultiCourtPickerView.swift */, ); path = Components; sourceTree = ""; @@ -2388,6 +2393,7 @@ FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */, FFBF41842BF75ED7001B24CB /* EventTournamentsView.swift in Sources */, FF1DC55B2BAB80C400FD8220 /* DisplayContext.swift in Sources */, + FF17CA4A2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */, FF9268072BCE94D90080F940 /* TournamentCallView.swift in Sources */, FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */, FF967CFC2BAEE52E00A9A3BD /* GroupStagesView.swift in Sources */, @@ -2657,6 +2663,7 @@ FF4CBFE92C996C0600151637 /* CloudConvert.swift in Sources */, FF4CBFEA2C996C0600151637 /* EventTournamentsView.swift in Sources */, FF4CBFEB2C996C0600151637 /* DisplayContext.swift in Sources */, + FF17CA4B2CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */, FF4CBFEC2C996C0600151637 /* TournamentCallView.swift in Sources */, FF4CBFED2C996C0600151637 /* LoserRoundsView.swift in Sources */, FF4CBFEE2C996C0600151637 /* GroupStagesView.swift in Sources */, @@ -2905,6 +2912,7 @@ FF70FB682C90584900129CC2 /* CloudConvert.swift in Sources */, FF70FB692C90584900129CC2 /* EventTournamentsView.swift in Sources */, FF70FB6A2C90584900129CC2 /* DisplayContext.swift in Sources */, + FF17CA492CB915A1003C7323 /* MultiCourtPickerView.swift in Sources */, FF70FB6B2C90584900129CC2 /* TournamentCallView.swift in Sources */, FF70FB6C2C90584900129CC2 /* LoserRoundsView.swift in Sources */, FF70FB6D2C90584900129CC2 /* GroupStagesView.swift in Sources */, diff --git a/PadelClub/Data/MatchScheduler.swift b/PadelClub/Data/MatchScheduler.swift index b1518af..869df0a 100644 --- a/PadelClub/Data/MatchScheduler.swift +++ b/PadelClub/Data/MatchScheduler.swift @@ -31,6 +31,7 @@ final class MatchScheduler : ModelObject, Storable { var groupStageChunkCount: Int? var overrideCourtsUnavailability: Bool = false var shouldTryToFillUpCourtsAvailable: Bool = false + var courtsAvailable: Set = Set() init(tournament: String, timeDifferenceLimit: Int = 5, @@ -42,7 +43,7 @@ final class MatchScheduler : ModelObject, Storable { rotationDifferenceIsImportant: Bool = false, shouldHandleUpperRoundSlice: Bool = true, shouldEndRoundBeforeStartingNext: Bool = true, - groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) { + groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false, courtsAvailable: Set = Set()) { self.tournament = tournament self.timeDifferenceLimit = timeDifferenceLimit self.loserBracketRotationDifference = loserBracketRotationDifference @@ -56,6 +57,7 @@ final class MatchScheduler : ModelObject, Storable { self.groupStageChunkCount = groupStageChunkCount self.overrideCourtsUnavailability = overrideCourtsUnavailability self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable + self.courtsAvailable = courtsAvailable } enum CodingKeys: String, CodingKey { @@ -73,6 +75,7 @@ final class MatchScheduler : ModelObject, Storable { case _groupStageChunkCount = "groupStageChunkCount" case _overrideCourtsUnavailability = "overrideCourtsUnavailability" case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable" + case _courtsAvailable = "courtsAvailable" } var courtsUnavailability: [DateInterval]? { @@ -99,7 +102,6 @@ final class MatchScheduler : ModelObject, Storable { if let specificGroupStage { groupStages = [specificGroupStage] } - let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount let matches = groupStages.flatMap { $0._matches() } matches.forEach({ @@ -127,7 +129,7 @@ final class MatchScheduler : ModelObject, Storable { lastDate = time } let groups = groupStages.filter({ $0.startDate == time }) - let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) + let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate) dispatch.timedMatches.forEach { matchSchedule in if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { @@ -151,7 +153,7 @@ final class MatchScheduler : ModelObject, Storable { Logger.error(error) } - let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) + let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate) dispatch.timedMatches.forEach { matchSchedule in if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { @@ -174,7 +176,7 @@ final class MatchScheduler : ModelObject, Storable { return lastDate } - func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher { + func groupStageDispatcher(groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher { let _groupStages = groupStages @@ -214,7 +216,7 @@ final class MatchScheduler : ModelObject, Storable { print("Match \(match.roundAndMatchTitle()) has teams already scheduled in rotation \(rotationIndex)") } return teamsAvailable - }).prefix(numberOfCourtsAvailablePerRotation)) + }).prefix(courtsAvailable.count)) if rotationIndex > 0 { rotationMatches = rotationMatches.sorted(by: { @@ -226,7 +228,7 @@ final class MatchScheduler : ModelObject, Storable { }) } - (0.. MatchDispatcher { + func roundDispatcher(flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { var slots = [TimeMatch]() var _startDate: Date? var rotationIndex = 0 @@ -436,7 +438,7 @@ final class MatchScheduler : ModelObject, Storable { var issueFound: Bool = false // Log start of the function - print("Starting roundDispatcher with \(availableMatchs.count) matches and \(numberOfCourtsAvailablePerRotation) courts available") + print("Starting roundDispatcher with \(availableMatchs.count) matches and \(courtsAvailable) courts available") flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in if _startDate == nil { @@ -455,8 +457,7 @@ final class MatchScheduler : ModelObject, Storable { } var freeCourtPerRotation = [Int: [Int]]() - let availableCourt = numberOfCourtsAvailablePerRotation - var courts = initialCourts ?? (0.. 0 while !availableMatchs.isEmpty && !issueFound && rotationIndex < 100 { @@ -468,7 +469,7 @@ final class MatchScheduler : ModelObject, Storable { rotationStartDate = dispatcherStartDate shouldStartAtDispatcherDate = false } else { - courts = rotationIndex == 0 ? courts : (0.. 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 + + if shouldTryToFillUpCourtsAvailable == false { + if roundObject.parent == nil && roundObject.index > 1 && 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 + } } } @@ -626,7 +636,7 @@ final class MatchScheduler : ModelObject, Storable { } - if freeCourtPerRotation[rotationIndex]?.count == availableCourts { + if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count { print("All courts in rotation \(rotationIndex) are free") } } @@ -713,7 +723,7 @@ final class MatchScheduler : ModelObject, Storable { print("initial available courts at beginning: \(courts ?? [])") - let roundDispatch = self.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts) + let roundDispatch = self.roundDispatcher(flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts) roundDispatch.timedMatches.forEach { matchSchedule in if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) { @@ -750,7 +760,50 @@ final class MatchScheduler : ModelObject, Storable { }) } + func getFirstFreeCourt(startDate: Date, duration: Int, courts: [Int], courtsUnavailability: [DateInterval]) -> (earliestFreeDate: Date, availableCourts: [Int]) { + var earliestEndDate: Date? + var availableCourtsAtEarliest: [Int] = [] + + // Iterate through each court and find the earliest time it becomes free + for courtIndex in courts { + let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex } + var isAvailable = true + + for interval in unavailabilityForCourt { + if interval.startDate <= startDate && interval.endDate > startDate { + isAvailable = false + if let currentEarliest = earliestEndDate { + earliestEndDate = min(currentEarliest, interval.endDate) + } else { + earliestEndDate = interval.endDate + } + } + } + + // If the court is available at the start date, add it to the list of available courts + if isAvailable { + availableCourtsAtEarliest.append(courtIndex) + } + } + + // If there are no unavailable courts, return the original start date and all courts + if let earliestEndDate = earliestEndDate { + // Find which courts will be available at the earliest free date + let courtsAvailableAtEarliest = courts.filter { courtIndex in + let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex } + return unavailabilityForCourt.allSatisfy { $0.endDate <= earliestEndDate } + } + return (earliestFreeDate: earliestEndDate, availableCourts: courtsAvailableAtEarliest) + } else { + // If no courts were unavailable, all courts are available at the start date + return (earliestFreeDate: startDate.addingTimeInterval(Double(duration) * 60), availableCourts: courts) + } + } + func updateSchedule(tournament: Tournament) -> Bool { + if tournament.courtCount < courtsAvailable.count { + courtsAvailable = Set(tournament.courtsAvailable()) + } var lastDate = tournament.startDate if tournament.groupStageCount > 0 { lastDate = updateGroupStageSchedule(tournament: tournament) diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index b0c08dc..a493e39 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -2045,6 +2045,10 @@ defer { return self._matchSchedulers().first } + func courtsAvailable() -> [Int] { + (0.. MonthData? { guard let rankSourceDate else { return nil } let dateString = URL.importDateFormatter.string(from: rankSourceDate) diff --git a/PadelClub/Views/Planning/Components/MultiCourtPickerView.swift b/PadelClub/Views/Planning/Components/MultiCourtPickerView.swift new file mode 100644 index 0000000..55c2b26 --- /dev/null +++ b/PadelClub/Views/Planning/Components/MultiCourtPickerView.swift @@ -0,0 +1,38 @@ +// +// MultiCourtPickerView.swift +// PadelClub +// +// Created by razmig on 11/10/2024. +// + +import SwiftUI + +struct MultiCourtPickerView: View { + @Bindable var matchScheduler: MatchScheduler + @Environment(Tournament.self) var tournament: Tournament + + var body: some View { + List { + ForEach(tournament.courtsAvailable(), id: \.self) { courtIndex in + LabeledContent { + Button { + if matchScheduler.courtsAvailable.contains(courtIndex) { + matchScheduler.courtsAvailable.remove(courtIndex) + } else { + matchScheduler.courtsAvailable.insert(courtIndex) + } + } label: { + if matchScheduler.courtsAvailable.contains(courtIndex) { + Image(systemName: "checkmark.circle.fill") + } + } + } label: { + Text(tournament.courtName(atIndex: courtIndex)) + } + } + } + .navigationTitle("Terrains disponibles") + .toolbarBackground(.visible, for: .navigationBar) + .environment(\.editMode, Binding.constant(EditMode.active)) + } +} diff --git a/PadelClub/Views/Planning/PlanningSettingsView.swift b/PadelClub/Views/Planning/PlanningSettingsView.swift index 0ee5485..1246d65 100644 --- a/PadelClub/Views/Planning/PlanningSettingsView.swift +++ b/PadelClub/Views/Planning/PlanningSettingsView.swift @@ -39,7 +39,7 @@ struct PlanningSettingsView: View { _groupStageChunkCount = State(wrappedValue: tournament.getGroupStageChunkValue()) } } else { - self.matchScheduler = MatchScheduler(tournament: tournament.id) + self.matchScheduler = MatchScheduler(tournament: tournament.id, courtsAvailable: Set(tournament.courtsAvailable())) self._groupStageChunkCount = State(wrappedValue: tournament.getGroupStageChunkValue()) } } @@ -68,7 +68,26 @@ struct PlanningSettingsView: View { CourtAvailabilitySettingsView(event: event) .environment(tournament) } label: { - Text("Indisponibilités des terrains") + LabeledContent { + Text(event.courtsUnavailability.count.formatted()) + } label: { + Text("Créneaux d'indisponibilités") + } + } + } + + NavigationLink { + MultiCourtPickerView(matchScheduler: matchScheduler) + .environment(tournament) + } label: { + LabeledContent { + Text(matchScheduler.courtsAvailable.count.formatted() + "/" + tournament.courtCount.formatted()) + } label: { + Text("Sélection des terrains") + if matchScheduler.courtsAvailable.count > tournament.courtCount { + Text("Attention !") + .tint(.red) + } } } } footer: { @@ -105,12 +124,23 @@ struct PlanningSettingsView: View { .foregroundStyle(.logoRed) } + let event = tournament.eventObject() Section { NavigationLink { _optionsView() } label: { Text("Voir plus d'options intelligentes") } + + if let event, event.tournaments.count > 1 { + Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) { + Text("Ne pas tenir compte des autres tournois") + } + } + } footer: { + if let event, event.tournaments.count > 1 { + Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi de cet événement soit toujours considéré comme libre.") + } } let allMatches = tournament.allMatches() @@ -271,30 +301,22 @@ struct PlanningSettingsView: View { } } -// Section { -// Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) { -// Text("Remplir au maximum les terrains d'une rotation") -// } -// } footer: { -// Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.") -// } -// Section { - Toggle(isOn: $matchScheduler.shouldHandleUpperRoundSlice) { - Text("Équilibrer les matchs d'une manche") + Toggle(isOn: $matchScheduler.shouldTryToFillUpCourtsAvailable) { + Text("Remplir au maximum les terrains d'une rotation") } } footer: { - Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.") + Text("Tout en tenant compte de l'option ci-dessous, Padel Club essaiera de remplir les créneaux à chaque rotation.") } Section { - Toggle(isOn: $matchScheduler.overrideCourtsUnavailability) { - Text("Ne pas tenir compte des autres tournois") + Toggle(isOn: $matchScheduler.shouldHandleUpperRoundSlice) { + Text("Équilibrer les matchs d'une manche") } } footer: { - Text("Cette option fait en sorte qu'un terrain pris par un match d'un autre tournoi est toujours considéré comme libre.") + Text("Cette option permet de programmer une manche sur plusieurs rotation de manière équilibrée dans le cas où il y a plus de matchs à jouer dans cette manche que de terrains.") } - + Section { Toggle(isOn: $matchScheduler.shouldEndRoundBeforeStartingNext) { Text("Finir une manche, classement inclus avant de continuer")