From 15323dbb4a423ffa8640802d2b972c678266b24d Mon Sep 17 00:00:00 2001 From: Razmig Sarkissian Date: Mon, 15 Apr 2024 09:19:33 +0200 Subject: [PATCH] update scheduler and planning stuff --- PadelClub.xcodeproj/project.pbxproj | 4 + PadelClub/Data/Match.swift | 40 ++++ PadelClub/Data/Round.swift | 29 +++ PadelClub/Data/TeamRegistration.swift | 13 +- PadelClub/Data/Tournament.swift | 11 +- PadelClub/Extensions/Date+Extensions.swift | 6 + PadelClub/ViewModel/MatchScheduler.swift | 190 +++++++++++++++--- PadelClub/Views/Match/MatchSetupView.swift | 12 ++ .../LoserRoundScheduleEditorView.swift | 141 +++++++++++++ .../Planning/MatchScheduleEditorView.swift | 11 +- .../Views/Planning/PlanningSettingsView.swift | 85 +++++++- .../Planning/RoundScheduleEditorView.swift | 25 +-- PadelClub/Views/Planning/SchedulerView.swift | 33 ++- .../Screen/TournamentScheduleView.swift | 51 ++--- .../Tournament/TournamentRunningView.swift | 14 +- 15 files changed, 553 insertions(+), 112 deletions(-) create mode 100644 PadelClub/Views/Planning/LoserRoundScheduleEditorView.swift diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 0c8a95c..39363a7 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -212,6 +212,7 @@ FFF964552BC266CF00EEF017 /* SchedulerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF964542BC266CF00EEF017 /* SchedulerView.swift */; }; FFF964572BC26B3400EEF017 /* RoundScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF964562BC26B3400EEF017 /* RoundScheduleEditorView.swift */; }; FFF9645B2BC2D53B00EEF017 /* GroupStageScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFF9645A2BC2D53B00EEF017 /* GroupStageScheduleEditorView.swift */; }; + FFFCDE0E2BCC833600317DEF /* LoserRoundScheduleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFCDE0D2BCC833600317DEF /* LoserRoundScheduleEditorView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -468,6 +469,7 @@ FFF964542BC266CF00EEF017 /* SchedulerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchedulerView.swift; sourceTree = ""; }; FFF964562BC26B3400EEF017 /* RoundScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundScheduleEditorView.swift; sourceTree = ""; }; FFF9645A2BC2D53B00EEF017 /* GroupStageScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupStageScheduleEditorView.swift; sourceTree = ""; }; + FFFCDE0D2BCC833600317DEF /* LoserRoundScheduleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundScheduleEditorView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1034,6 +1036,7 @@ FFF964522BC262B000EEF017 /* PlanningSettingsView.swift */, FFF964542BC266CF00EEF017 /* SchedulerView.swift */, FFF964562BC26B3400EEF017 /* RoundScheduleEditorView.swift */, + FFFCDE0D2BCC833600317DEF /* LoserRoundScheduleEditorView.swift */, FFF527D52BC6DDD000FF4EF2 /* MatchScheduleEditorView.swift */, FFF9645A2BC2D53B00EEF017 /* GroupStageScheduleEditorView.swift */, ); @@ -1321,6 +1324,7 @@ FFCFC0162BBC5A4C00B82851 /* SetInputView.swift in Sources */, FF5D0D892BB4935C005CB568 /* ClubRowView.swift in Sources */, FF1DC5512BAB351300FD8220 /* ClubDetailView.swift in Sources */, + FFFCDE0E2BCC833600317DEF /* LoserRoundScheduleEditorView.swift in Sources */, C4A47D632B6D3D6500ADC637 /* Club.swift in Sources */, FF6EC90B2B947AC000EA7F5A /* Array+Extensions.swift in Sources */, FF59FFB92B90EFD70061EFF9 /* ToolboxView.swift in Sources */, diff --git a/PadelClub/Data/Match.swift b/PadelClub/Data/Match.swift index 69baa1e..c3a9669 100644 --- a/PadelClub/Data/Match.swift +++ b/PadelClub/Data/Match.swift @@ -66,11 +66,45 @@ class Match: ModelObject, Storable { } } + func isSeedLocked(atTeamPosition teamPosition: TeamPosition) -> Bool { + previousMatch(teamPosition)?.disabled == true + } + + func unlockSeedPosition(atTeamPosition teamPosition: TeamPosition) { + previousMatch(teamPosition)?.enableMatch() + } + + @discardableResult + func lockAndGetSeedPosition(atTeamPosition slot: TeamPosition?, opposingSeeding: Bool = false) -> Int { + let matchIndex = index + var teamPosition : TeamPosition { + if let slot { + return slot + } else { + let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound) + let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2) + var teamPosition = slot ?? (isUpper ? .one : .two) + if opposingSeeding { + teamPosition = slot ?? (isUpper ? .two : .one) + } + return teamPosition + } + } + previousMatch(teamPosition)?.disableMatch() + return matchIndex * 2 + teamPosition.rawValue + } + func isSeededBy(team: TeamRegistration, inTeamPosition teamPosition: TeamPosition) -> Bool { guard let bracketPosition = team.bracketPosition else { return false } return index * 2 + teamPosition.rawValue == bracketPosition } + func estimatedEndDate() -> Date? { + let minutesToAdd = Double(matchFormat.estimatedDuration) + return startDate?.addingTimeInterval(minutesToAdd * 60.0) + } + func resetMatch() { losingTeamId = nil winningTeamId = nil @@ -281,6 +315,12 @@ class Match: ModelObject, Storable { } } + func courtIndex() -> Int? { + guard let court else { return nil } + if let courtIndex = Int(court) { return courtIndex - 1 } + return nil + } + func courtCount() -> Int { currentTournament()?.courtCount ?? 1 } diff --git a/PadelClub/Data/Round.swift b/PadelClub/Data/Round.swift index 9050978..cd0edcb 100644 --- a/PadelClub/Data/Round.swift +++ b/PadelClub/Data/Round.swift @@ -200,6 +200,35 @@ class Round: ModelObject, Storable { _matches().allSatisfy({ $0.disabled }) } + func resetRound(updateMatchFormat: MatchFormat? = nil) { + let _updateMatchFormat = updateMatchFormat ?? self.matchFormat + _matches().forEach({ + $0.startDate = nil + $0.matchFormat = updateMatchFormat ?? $0.matchFormat + }) + self.matchFormat = _updateMatchFormat + loserRoundsAndChildren().forEach { round in + round.resetRound(updateMatchFormat: _updateMatchFormat) + } + nextRound()?.resetRound(updateMatchFormat: _updateMatchFormat) + } + + func resetRound(from match: Match, updateMatchFormat: MatchFormat? = nil) { + let _updateMatchFormat = updateMatchFormat ?? self.matchFormat + self.matchFormat = _updateMatchFormat + let matches = _matches() + if let index = matches.firstIndex(where: { $0.id == match.id }) { + matches[index...].forEach { match in + match.startDate = nil + match.matchFormat = _updateMatchFormat + } + } + loserRoundsAndChildren().forEach { round in + round.resetRound(updateMatchFormat: _updateMatchFormat) + } + nextRound()?.resetRound(updateMatchFormat: _updateMatchFormat) + } + func getActiveLoserRound() -> Round? { let rounds = loserRounds() return rounds.filter({ $0.hasStarted() && $0.hasEnded() == false && $0.isDisabled() == false }).sorted(by: \.index).reversed().first ?? rounds.first(where: { $0.isDisabled() == false }) diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index d945a78..4171037 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -54,18 +54,11 @@ class TeamRegistration: ModelObject, Storable { } func setSeedPosition(inSpot match: Match, slot: TeamPosition?, opposingSeeding: Bool) { - let matchIndex = match.index - let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex) - let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound) - let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2) - var teamPosition = slot ?? (isUpper ? .one : .two) - if opposingSeeding { - teamPosition = slot ?? (isUpper ? .two : .one) - } - match.previousMatch(teamPosition)?.disableMatch() - bracketPosition = matchIndex * 2 + teamPosition.rawValue + let seedPosition = match.lockAndGetSeedPosition(atTeamPosition: slot, opposingSeeding: opposingSeeding) + bracketPosition = seedPosition } + var initialWeight: Int { lockWeight ?? weight } diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index 63ecbed..26fe5d4 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -86,6 +86,12 @@ class Tournament : ModelObject, Storable { case build } + func getCourtIndex(_ court: String?) -> Int? { + guard let court else { return nil } + if let courtIndex = Int(court) { return courtIndex - 1 } + return nil + } + func courtUsed() -> [String] { let runningMatches : [Match] = Store.main.filter(isIncluded: { $0.isRunning() }).filter({ $0.tournamentId() == self.id }) return Set(runningMatches.compactMap { $0.court }).sorted() @@ -127,7 +133,8 @@ class Tournament : ModelObject, Storable { } func state() -> Tournament.State { - if groupStageCount > 0 && groupStages().isEmpty == false { + if (groupStageCount > 0 && groupStages().isEmpty == false) + || rounds().isEmpty == false { return .build } return .initial @@ -348,7 +355,7 @@ class Tournament : ModelObject, Storable { } let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) - print(id, tournamentTitle(), duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + //print("func selectedSortedTeams", id, tournamentTitle(), duration.formatted(.units(allowed: [.seconds, .milliseconds]))) return _sortedTeams } diff --git a/PadelClub/Extensions/Date+Extensions.swift b/PadelClub/Extensions/Date+Extensions.swift index 9fae765..67e902f 100644 --- a/PadelClub/Extensions/Date+Extensions.swift +++ b/PadelClub/Extensions/Date+Extensions.swift @@ -181,3 +181,9 @@ extension Date { } } +extension Date { + func isEarlierThan(_ date: Date) -> Bool { + self < date + } +} + diff --git a/PadelClub/ViewModel/MatchScheduler.swift b/PadelClub/ViewModel/MatchScheduler.swift index b1471e4..3337951 100644 --- a/PadelClub/ViewModel/MatchScheduler.swift +++ b/PadelClub/ViewModel/MatchScheduler.swift @@ -6,6 +6,7 @@ // import Foundation +import LeStorage struct GroupStageTimeMatch { let matchID: String @@ -51,10 +52,42 @@ extension Match { } } +enum MatchSchedulerOption: Hashable { + case accountUpperBracketBreakTime + case accountLoserBracketBreakTime + case randomizeCourts + case rotationDifferenceIsImportant + case shouldHandleUpperRoundSlice +} + class MatchScheduler { static let shared = MatchScheduler() - - func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date?, randomizeCourts: Bool) -> GroupStageMatchDispatcher { + var options: Set = Set(arrayLiteral: .accountUpperBracketBreakTime) + var timeDifferenceLimit: Double = 300.0 + var loserBracketRotationDifference: Int = 0 + var upperBracketRotationDifference: Int = 1 + + func shouldHandleUpperRoundSlice() -> Bool { + options.contains(.shouldHandleUpperRoundSlice) + } + + func accountLoserBracketBreakTime() -> Bool { + options.contains(.accountLoserBracketBreakTime) + } + + func accountUpperBracketBreakTime() -> Bool { + options.contains(.accountUpperBracketBreakTime) + } + + func randomizeCourts() -> Bool { + options.contains(.randomizeCourts) + } + + func rotationDifferenceIsImportant() -> Bool { + options.contains(.rotationDifferenceIsImportant) + } + + func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date?) -> GroupStageMatchDispatcher { let _groupStages = groupStages @@ -120,7 +153,7 @@ class MatchScheduler { 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()) let previousMatches = roundObject.precedentMatches(ofMatch: match) @@ -156,13 +197,28 @@ class MatchScheduler { return false } - let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex < rotationIndex }) - guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: roundObject.isLoserBracket() == false) }).max() else { + 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 { - return true + if rotationDifferenceIsImportant() { + return previousMatchIsInPreviousRotation + } else { + return true + } } else { if targetedStartDate == minimumTargetedEndDate { minimumTargetedEndDate = minimumPossibleEndDate @@ -191,24 +247,66 @@ class MatchScheduler { ) } - func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], randomizeCourts: Bool, dispatcherStartDate: Date) -> MatchDispatcher { - + func getAvailableCourts(from matches: [Match]) -> [(String, Date)] { + let validMatches = matches.filter({ $0.court != nil && $0.startDate != nil }) + let byCourt = Dictionary(grouping: validMatches, by: { $0.court! }) + return (byCourt.keys.flatMap { court in + let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!) + let lastMatch = matchesByCourt?.last + var results = [(String, Date)]() + if let courtFreeDate = lastMatch?.estimatedEndDate() { + results.append((court, courtFreeDate)) + } + return results + } + ) + } + + func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { + var slots = [TimeMatch]() - var availableMatchs = flattenedMatches + var _startDate: Date? var rotationIndex = 0 + var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) + + 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.estimatedDuration, minimumBreakTime: match.matchFormat.breakTime.breakTime) + slots.append(timeMatch) + } + + if slots.isEmpty == false { + rotationIndex += 1 + } + var freeCourtPerRotation = [Int: [Int]]() - var courts = [Int]() + + let availableCourt = numberOfCourtsAvailablePerRotation + + var courts = initialCourts ?? (0.. 0 while availableMatchs.count > 0 { freeCourtPerRotation[rotationIndex] = [] - var matchPerRound = [Int: Int]() - var availableCourt = numberOfCourtsAvailablePerRotation - 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) }) @@ -224,11 +322,11 @@ class MatchScheduler { var difference = differenceWithBreak if differenceWithBreak <= 0 { difference = differenceWithoutBreak - } else if differenceWithBreak > 0 && differenceWithoutBreak > 0 { + } else if differenceWithBreak > timeDifferenceLimit && differenceWithoutBreak > timeDifferenceLimit { difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak) } - if difference > 0 { + if difference > timeDifferenceLimit { courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index) }) freeCourtPerRotation[rotationIndex] = courts @@ -245,7 +343,7 @@ class MatchScheduler { var organizedSlots = [TimeMatch]() for i in 0.. courts.count { + if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { return false } + } + } + if roundObject.loser == nil && roundObject.index > 0, match.indexInRound() == 0, courts.count > 1, let nextMatch = match.next() { if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { return true @@ -275,7 +383,7 @@ class MatchScheduler { } } - if (matchPerRound[roundObject.index] ?? 0)%2 == 0 && roundObject.index != 0 && roundObject.loser == nil && courtIndex == courts.count - 1 { + if currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.loser == nil && courtIndex == courts.count - 1 { return false } @@ -309,9 +417,12 @@ class MatchScheduler { } } - func updateSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, randomizeCourts: Bool, startDate: Date) { + func updateSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) { let upperRounds = tournament.rounds() + let allMatches = tournament.allMatches() + + var roundIndex = 0 let rounds = upperRounds.map { @@ -319,22 +430,41 @@ class MatchScheduler { } + upperRounds.flatMap { $0.loserRoundsAndChildren() } - - if let roundId { - roundIndex = rounds.firstIndex(where: { $0.id == roundId }) ?? 0 - } - - var flattenedMatches = rounds[roundIndex...].flatMap { round in + + var 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 + } + }) - if let matchId, let matchIndex = flattenedMatches.firstIndex(where: { $0.id == matchId }) { - flattenedMatches = Array(flattenedMatches[matchIndex...]) + if let roundId { + if let round : Round = Store.main.findById(roundId) { + let matches = round._matches() + round.resetRound() + flattenedMatches = matches + flattenedMatches + } + + } else if let matchId { + if let match : Match = Store.main.findById(matchId) { + if let round = match.roundObject { + round.resetRound(from: match) + } + flattenedMatches = [match] + flattenedMatches + } } - flattenedMatches.forEach({ $0.startDate = nil }) - - let roundDispatch = self.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, randomizeCourts: randomizeCourts, dispatcherStartDate: startDate) + let usedCourts = getAvailableCourts(from: allMatches.filter({ $0.startDate?.isEarlierThan(startDate) == true })) + let initialCourts = usedCourts.filter { (court, availableDate) in + availableDate <= startDate + }.sorted(by: \.1).compactMap { tournament.getCourtIndex($0.0) } + + let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts + + 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 }) { @@ -343,7 +473,7 @@ class MatchScheduler { } } - try? DataStore.shared.matches.addOrUpdate(contentOfs: flattenedMatches) + try? DataStore.shared.matches.addOrUpdate(contentOfs: allMatches) } } diff --git a/PadelClub/Views/Match/MatchSetupView.swift b/PadelClub/Views/Match/MatchSetupView.swift index 9b30547..6a26fd6 100644 --- a/PadelClub/Views/Match/MatchSetupView.swift +++ b/PadelClub/Views/Match/MatchSetupView.swift @@ -91,6 +91,18 @@ struct MatchSetupView: View { Text("Tirage").tag(nil as SeedInterval?) } .disabled(availableSeedGroups.isEmpty && walkOutSpot == false) + + if match.isSeedLocked(atTeamPosition: teamPosition) { + Button("Libérer") { + match.unlockSeedPosition(atTeamPosition: teamPosition) + try? dataStore.matches.addOrUpdate(instance: match) + } + } else { + Button("Réserver") { + _ = match.lockAndGetSeedPosition(atTeamPosition: teamPosition) + try? dataStore.matches.addOrUpdate(instance: match) + } + } } } .fixedSize(horizontal: false, vertical: true) diff --git a/PadelClub/Views/Planning/LoserRoundScheduleEditorView.swift b/PadelClub/Views/Planning/LoserRoundScheduleEditorView.swift new file mode 100644 index 0000000..50fc9d6 --- /dev/null +++ b/PadelClub/Views/Planning/LoserRoundScheduleEditorView.swift @@ -0,0 +1,141 @@ +// +// LoserRoundScheduleEditorView.swift +// PadelClub +// +// Created by Razmig Sarkissian on 14/04/2024. +// + +import SwiftUI +struct LoserRoundStepScheduleEditorView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament: Tournament + + var round: Round + var upperRound: Round + var matches: [Match] + @State private var startDate: Date + @State private var matchFormat: MatchFormat + + init(round: Round, upperRound: Round) { + self.upperRound = upperRound + self.round = round + let _matches = upperRound.loserRounds(forRoundIndex: round.index).flatMap({ $0.playedMatches() }) + self.matches = _matches + self._startDate = State(wrappedValue: round.startDate ?? _matches.first?.startDate ?? Date()) + self._matchFormat = State(wrappedValue: round.matchFormat) + } + + var body: some View { + @Bindable var round = round + Section { + MatchFormatPickerView(headerLabel: "Format", matchFormat: $round.matchFormat) + DatePicker(selection: $startDate) { + Text(startDate.formatted(.dateTime.weekday())) + } + + RowButtonView("Valider la modification") { + _updateSchedule() + } + + } header: { + Text(round.selectionLabel()) + } footer: { + NavigationLink { + List { + ForEach(matches) { match in + if match.disabled == false { + MatchScheduleEditorView(match: match) + } + } + } + .headerProminence(.increased) + .navigationTitle(round.selectionLabel()) + .environment(tournament) + } label: { + Text("voir tous les matchs") + } + } + .headerProminence(.increased) + } + + private func _updateSchedule() { + upperRound.loserRounds(forRoundIndex: round.index).forEach({ round in + round.resetRound(updateMatchFormat: round.matchFormat) + }) + + try? dataStore.matches.addOrUpdate(contentOfs: matches) + _save() + + MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: round.id, fromMatchId: nil, startDate: startDate) + _save() + } + + private func _save() { + try? dataStore.rounds.addOrUpdate(contentOfs: upperRound.loserRounds(forRoundIndex: round.index)) + } +} + +struct LoserRoundScheduleEditorView: View { + @EnvironmentObject var dataStore: DataStore + @Environment(Tournament.self) var tournament: Tournament + + var upperRound: Round + var loserRounds: [Round] + @State private var startDate: Date + @State private var matchFormat: MatchFormat + + init(upperRound: Round) { + self.upperRound = upperRound + let _loserRounds = upperRound.loserRounds() + self.loserRounds = _loserRounds + self._startDate = State(wrappedValue: _loserRounds.first?.startDate ?? _loserRounds.first?.playedMatches().first?.startDate ?? Date()) + self._matchFormat = State(wrappedValue: _loserRounds.first?.matchFormat ?? upperRound.matchFormat) + } + + var body: some View { + List { + Section { + MatchFormatPickerView(headerLabel: "Format", matchFormat: $matchFormat) + DatePicker(selection: $startDate) { + Text(startDate.formatted(.dateTime.weekday())) + } + RowButtonView("Valider la modification") { + _updateSchedule() + } + } header: { + Text("Classement " + upperRound.roundTitle()) + } + + + ForEach(upperRound.loserRounds()) { loserRound in + if loserRound.isDisabled() == false { + LoserRoundStepScheduleEditorView(round: loserRound, upperRound: upperRound) + } + } + } + .headerProminence(.increased) + .navigationTitle("Réglages") + .toolbarBackground(.visible, for: .navigationBar) + .navigationBarTitleDisplayMode(.inline) + } + + private func _updateSchedule() { + let matches = upperRound.loserRounds().flatMap({ round in + round.playedMatches() + }) + upperRound.loserRounds().forEach({ round in + round.resetRound(updateMatchFormat: matchFormat) + }) + + try? dataStore.matches.addOrUpdate(contentOfs: matches) + _save() + + MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: upperRound.loserRounds().first?.id, fromMatchId: nil, startDate: startDate) + _save() + } + + private func _save() { + try? dataStore.rounds.addOrUpdate(contentOfs: upperRound.loserRounds()) + } + +} diff --git a/PadelClub/Views/Planning/MatchScheduleEditorView.swift b/PadelClub/Views/Planning/MatchScheduleEditorView.swift index 10fa53f..b1af2c8 100644 --- a/PadelClub/Views/Planning/MatchScheduleEditorView.swift +++ b/PadelClub/Views/Planning/MatchScheduleEditorView.swift @@ -22,16 +22,21 @@ struct MatchScheduleEditorView: View { DatePicker(selection: $startDate) { Text(startDate.formatted(.dateTime.weekday())) } - RowButtonView("Modifier") { + RowButtonView("Valider la modification") { _updateSchedule() } } header: { - Text(match.matchTitle()) + if let round = match.roundObject { + Text(round.roundTitle() + " " + match.matchTitle()) + } else { + Text(match.matchTitle()) + } } + .headerProminence(.increased) } private func _updateSchedule() { - MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: match.round, fromMatchId: match.id, randomizeCourts: true, startDate: startDate) + MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: match.round, fromMatchId: match.id, startDate: startDate) } } diff --git a/PadelClub/Views/Planning/PlanningSettingsView.swift b/PadelClub/Views/Planning/PlanningSettingsView.swift index 5686f94..69e9559 100644 --- a/PadelClub/Views/Planning/PlanningSettingsView.swift +++ b/PadelClub/Views/Planning/PlanningSettingsView.swift @@ -11,12 +11,27 @@ struct PlanningSettingsView: View { @EnvironmentObject var dataStore: DataStore var tournament: Tournament @State private var scheduleSetup: Bool = false - @State private var randomCourtDistribution: Bool = false + @State private var randomCourtDistribution: Bool @State private var groupStageCourtCount: Int + @State private var upperBracketBreakTime: Bool + @State private var loserBracketBreakTime: Bool + @State private var rotationDifferenceIsImportant: Bool + @State private var loserBracketRotationDifference: Int + @State private var upperBracketRotationDifference: Int + @State private var timeDifferenceLimit: Double + @State private var shouldHandleUpperRoundSlice: Bool init(tournament: Tournament) { self.tournament = tournament self._groupStageCourtCount = State(wrappedValue: tournament.groupStageCourtCount ?? 1) + self._loserBracketRotationDifference = State(wrappedValue: MatchScheduler.shared.loserBracketRotationDifference) + self._upperBracketRotationDifference = State(wrappedValue: MatchScheduler.shared.upperBracketRotationDifference) + self._timeDifferenceLimit = State(wrappedValue: MatchScheduler.shared.timeDifferenceLimit) + self._rotationDifferenceIsImportant = State(wrappedValue: MatchScheduler.shared.rotationDifferenceIsImportant()) + self._randomCourtDistribution = State(wrappedValue: MatchScheduler.shared.randomizeCourts()) + self._upperBracketBreakTime = State(wrappedValue: MatchScheduler.shared.accountUpperBracketBreakTime()) + self._loserBracketBreakTime = State(wrappedValue: MatchScheduler.shared.accountLoserBracketBreakTime()) + self._shouldHandleUpperRoundSlice = State(wrappedValue: MatchScheduler.shared.shouldHandleUpperRoundSlice()) } var body: some View { @@ -39,8 +54,11 @@ struct PlanningSettingsView: View { Section { TournamentFieldsManagerView(localizedStringKey: "Terrains maximum", count: $tournament.courtCount) - TournamentFieldsManagerView(localizedStringKey: "Terrains par poule", count: $groupStageCourtCount) - + + if tournament.groupStages().isEmpty == false { + TournamentFieldsManagerView(localizedStringKey: "Terrains par poule", count: $groupStageCourtCount) + } + NavigationLink { } label: { @@ -54,6 +72,38 @@ struct PlanningSettingsView: View { Text("Distribuer les terrains au hasard") } + Toggle(isOn: $shouldHandleUpperRoundSlice) { + Text("Équilibrer les matchs d'une manche sur plusieurs tours") + } + + Toggle(isOn: $upperBracketBreakTime) { + Text("Tableau : tenir compte des pauses") + } + + Toggle(isOn: $loserBracketBreakTime) { + Text("Classement : tenir compte des pauses") + } + + Toggle(isOn: $rotationDifferenceIsImportant) { + Text("Forcer un créneau supplémentaire entre 2 phases") + } + + LabeledContent { + StepperView(count: $upperBracketRotationDifference, minimum: 0, maximum: 2) + } label: { + Text("Tableau") + } + .disabled(rotationDifferenceIsImportant == false) + + LabeledContent { + StepperView(count: $loserBracketRotationDifference, minimum: 0, maximum: 2) + } label: { + Text("Classement") + } + .disabled(rotationDifferenceIsImportant == false) + + //timeDifferenceLimit + RowButtonView("Horaire intelligent", role: .destructive) { _setupSchedule() } @@ -96,7 +146,32 @@ struct PlanningSettingsView: View { let groupStages = tournament.groupStages() let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount let matchScheduler = MatchScheduler.shared + matchScheduler.options.removeAll() + + if randomCourtDistribution { + matchScheduler.options.insert(.randomizeCourts) + } + + if shouldHandleUpperRoundSlice { + matchScheduler.options.insert(.shouldHandleUpperRoundSlice) + } + if upperBracketBreakTime { + matchScheduler.options.insert(.accountUpperBracketBreakTime) + } + + if loserBracketBreakTime { + matchScheduler.options.insert(.accountLoserBracketBreakTime) + } + + if rotationDifferenceIsImportant { + matchScheduler.options.insert(.rotationDifferenceIsImportant) + } + + matchScheduler.loserBracketRotationDifference = loserBracketRotationDifference + matchScheduler.upperBracketRotationDifference = upperBracketRotationDifference + matchScheduler.timeDifferenceLimit = timeDifferenceLimit + let matches = tournament.groupStages().flatMap({ $0._matches() }) matches.forEach({ $0.startDate = nil }) @@ -112,7 +187,7 @@ struct PlanningSettingsView: View { groups.forEach({ $0.startDate = lastDate }) try? dataStore.groupStages.addOrUpdate(contentOfs: groups) - let dispatch = matchScheduler.groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate, randomizeCourts: randomCourtDistribution) + let dispatch = matchScheduler.groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) dispatch.timedMatches.forEach { matchSchedule in if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { @@ -128,7 +203,7 @@ struct PlanningSettingsView: View { } try? dataStore.matches.addOrUpdate(contentOfs: matches) - matchScheduler.updateSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, randomizeCourts: randomCourtDistribution, startDate: lastDate) + matchScheduler.updateSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) scheduleSetup = true diff --git a/PadelClub/Views/Planning/RoundScheduleEditorView.swift b/PadelClub/Views/Planning/RoundScheduleEditorView.swift index 17d85b8..d16ba98 100644 --- a/PadelClub/Views/Planning/RoundScheduleEditorView.swift +++ b/PadelClub/Views/Planning/RoundScheduleEditorView.swift @@ -24,13 +24,10 @@ struct RoundScheduleEditorView: View { List { Section { MatchFormatPickerView(headerLabel: "Format", matchFormat: $round.matchFormat) - } - - Section { DatePicker(selection: $startDate) { Text(startDate.formatted(.dateTime.weekday())) } - RowButtonView("Modifier") { + RowButtonView("Valider la modification") { _updateSchedule() } } @@ -39,20 +36,18 @@ struct RoundScheduleEditorView: View { MatchScheduleEditorView(match: match) } } - .onChange(of: round.matchFormat) { - let matches = round._matches() - matches.forEach { match in - match.matchFormat = round.matchFormat - } - try? dataStore.matches.addOrUpdate(contentOfs: matches) - _save() - - _updateSchedule() - } } private func _updateSchedule() { - MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: round.id, fromMatchId: nil, randomizeCourts: true, startDate: startDate) + let matches = round._matches() + matches.forEach { match in + match.matchFormat = round.matchFormat + } + try? dataStore.matches.addOrUpdate(contentOfs: matches) + _save() + + MatchScheduler.shared.updateSchedule(tournament: tournament, fromRoundId: round.id, fromMatchId: nil, startDate: startDate) + _save() } private func _save() { diff --git a/PadelClub/Views/Planning/SchedulerView.swift b/PadelClub/Views/Planning/SchedulerView.swift index fa25239..ab4a982 100644 --- a/PadelClub/Views/Planning/SchedulerView.swift +++ b/PadelClub/Views/Planning/SchedulerView.swift @@ -20,19 +20,21 @@ extension Round: Schedulable { struct SchedulerView: View { var tournament: Tournament - + var destination: ScheduleDestination + var body: some View { List { - ForEach(tournament.groupStages()) { - _schedulerView($0) - } - ForEach(tournament.rounds()) { round in - _schedulerView(round) - ForEach(round.loserRoundsAndChildren()) { loserRound in - if loserRound.isDisabled() == false { - _schedulerView(loserRound) - } + switch destination { + case .scheduleGroupStage: + ForEach(tournament.groupStages()) { + _schedulerView($0) + } + case .scheduleBracket: + ForEach(tournament.rounds()) { round in + _schedulerView(round) } + default: + EmptyView() } } .headerProminence(.increased) @@ -63,12 +65,21 @@ struct SchedulerView: View { } } } + if let round = schedulable as? Round { + NavigationLink { + LoserRoundScheduleEditorView(upperRound: round) + .environment(tournament) + } label: { + Text("Match de classement \(round.roundTitle(.short))") + } + } } header: { Text(schedulable.titleLabel()) } + .headerProminence(.increased) } } #Preview { - SchedulerView(tournament: Tournament.mock()) + SchedulerView(tournament: Tournament.mock(), destination: .scheduleBracket) } diff --git a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift index 83795fc..93b3881 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift @@ -20,33 +20,21 @@ extension Schedulable { } } -enum ScheduleDestination: Identifiable, Selectable { - var id: String { - switch self { - case .groupStage(let groupStage): - return groupStage.id - case .round(let round): - return round.id - default: - return String(describing: self) - } - } +enum ScheduleDestination: String, Identifiable, Selectable { + var id: String { self.rawValue } case planning - case schedule - case groupStage(GroupStage) - case round(Round) + case scheduleGroupStage + case scheduleBracket func selectionLabel() -> String { switch self { - case .schedule: - return "Horaires" + case .scheduleGroupStage: + return "Poules" + case .scheduleBracket: + return "Tableau" case .planning: - return "Progr." - case .groupStage(let groupStage): - return groupStage.selectionLabel() - case .round(let round): - return round.selectionLabel() + return "Programmation" } } @@ -59,12 +47,17 @@ struct TournamentScheduleView: View { var tournament: Tournament @State private var selectedScheduleDestination: ScheduleDestination? = nil let allDestinations: [ScheduleDestination] - let verticalDestinations: [ScheduleDestination] init(tournament: Tournament) { self.tournament = tournament - self.verticalDestinations = tournament.groupStages().map({ .groupStage($0) }) + tournament.rounds().map({ .round($0) }) - self.allDestinations = [.schedule, .planning] + var destinations = [ScheduleDestination.planning] + if tournament.groupStages().isEmpty == false { + destinations.append(.scheduleGroupStage) + } + if tournament.rounds().isEmpty == false { + destinations.append(.scheduleBracket) + } + self.allDestinations = destinations } var body: some View { @@ -76,14 +69,12 @@ struct TournamentScheduleView: View { .navigationTitle("Réglages") case .some(let selectedSchedule): switch selectedSchedule { - case .groupStage(let groupStage): - Text("ok") - case .round(let round): - Text("ok") + case .scheduleGroupStage: + SchedulerView(tournament: tournament, destination: selectedSchedule) + case .scheduleBracket: + SchedulerView(tournament: tournament, destination: selectedSchedule) case .planning: PlanningView(matches: tournament.allMatches()) - case .schedule: - SchedulerView(tournament: tournament) } } } diff --git a/PadelClub/Views/Tournament/TournamentRunningView.swift b/PadelClub/Views/Tournament/TournamentRunningView.swift index 8e4312f..9f8209b 100644 --- a/PadelClub/Views/Tournament/TournamentRunningView.swift +++ b/PadelClub/Views/Tournament/TournamentRunningView.swift @@ -22,12 +22,14 @@ struct TournamentRunningView: View { } } - Section { - NavigationLink(value: Screen.groupStage) { - LabeledContent { - Text(tournament.groupStageStatus()) - } label: { - Text("Poules") + if tournament.groupStages().isEmpty == false { + Section { + NavigationLink(value: Screen.groupStage) { + LabeledContent { + Text(tournament.groupStageStatus()) + } label: { + Text("Poules") + } } } }