diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index 2475782..5da9c1a 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -213,6 +213,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 */ @@ -470,6 +471,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 */ @@ -1037,6 +1039,7 @@ FFF964522BC262B000EEF017 /* PlanningSettingsView.swift */, FFF964542BC266CF00EEF017 /* SchedulerView.swift */, FFF964562BC26B3400EEF017 /* RoundScheduleEditorView.swift */, + FFFCDE0D2BCC833600317DEF /* LoserRoundScheduleEditorView.swift */, FFF527D52BC6DDD000FF4EF2 /* MatchScheduleEditorView.swift */, FFF9645A2BC2D53B00EEF017 /* GroupStageScheduleEditorView.swift */, ); @@ -1325,6 +1328,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/Coredata/Persistence.swift b/PadelClub/Data/Coredata/Persistence.swift index e5953b6..56ac96e 100644 --- a/PadelClub/Data/Coredata/Persistence.swift +++ b/PadelClub/Data/Coredata/Persistence.swift @@ -99,6 +99,7 @@ class PersistenceController: NSObject { func batchInsertPlayers(_ importedPlayers: [FederalPlayer], importingDate: Date) async { guard !importedPlayers.isEmpty else { return } let context = newTaskContext() + context.performAndWait { context.transactionAuthor = PersistenceController.remoteDataImportAuthorName @@ -121,7 +122,16 @@ class PersistenceController: NSObject { // 1 var index = 0 let total = imported.count - + let replacements: [(Character, Character)] = [("Á", "ç"), ("‡", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("…", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")] + + + let replacementsCharacters = replacements + var fixApril2024 = false + //april 04-2024 bug with accent characters / adobe / fft + if URL.importDateFormatter.string(from: importingDate) == "04-2024" { + fixApril2024 = true + } + // 2 let batchInsert = NSBatchInsertRequest( entity: ImportedPlayer.entity()) { (managedObject: NSManagedObject) -> Bool in @@ -133,15 +143,34 @@ class PersistenceController: NSObject { let data = imported[index] importedPlayer.license = data.license importedPlayer.ligueName = data.ligue + if fixApril2024 { + importedPlayer.ligueName?.replace(characters: replacementsCharacters) + } + importedPlayer.rank = Int64(data.rank) importedPlayer.points = data.points ?? 0 importedPlayer.assimilation = data.assimilation importedPlayer.country = data.country + if fixApril2024 { + importedPlayer.country?.replace(characters: replacementsCharacters) + } importedPlayer.tournamentCount = Int64(data.tournamentCount ?? 0) importedPlayer.lastName = data.lastName + if fixApril2024 { + importedPlayer.lastName?.replace(characters: replacementsCharacters) + } importedPlayer.firstName = data.firstName + if fixApril2024 { + importedPlayer.firstName?.replace(characters: replacementsCharacters) + } importedPlayer.fullName = data.firstName + " " + data.lastName + if fixApril2024 { + importedPlayer.fullName?.replace(characters: replacementsCharacters) + } importedPlayer.clubName = data.club + if fixApril2024 { + importedPlayer.clubName?.replace(characters: replacementsCharacters) + } importedPlayer.clubCode = data.clubCode.replaceCharactersFromSet(characterSet: .whitespaces) importedPlayer.male = data.isMale importedPlayer.importDate = importingDate diff --git a/PadelClub/Data/GroupStage.swift b/PadelClub/Data/GroupStage.swift index c703675..edca863 100644 --- a/PadelClub/Data/GroupStage.swift +++ b/PadelClub/Data/GroupStage.swift @@ -98,7 +98,16 @@ class GroupStage: ModelObject, Storable { func scoreLabel(forGroupStagePosition groupStagePosition: Int) -> String? { if let scoreData = _score(forGroupStagePosition: groupStagePosition) { - return "\(scoreData.wins)/\(scoreData.loses) " + scoreData.setDifference.formatted(.number.sign(strategy: .always(includingZero: false))) + let hideGameDifference = matchFormat.setsToWin == 1 + let setDifference = scoreData.setDifference.formatted(.number.sign(strategy: .always(includingZero: false))) + let gameDifference = scoreData.gameDifference.formatted(.number.sign(strategy: .always(includingZero: false))) + var differenceAsString = "\n" + gameDifference + " jeux" + if hideGameDifference == false { + differenceAsString = "\n" + setDifference + " sets" + differenceAsString + } else { + differenceAsString = setDifference + } + return "\(scoreData.wins)/\(scoreData.loses) " + differenceAsString } else { return nil } diff --git a/PadelClub/Data/Match.swift b/PadelClub/Data/Match.swift index ee4cd30..ae76ae3 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 @@ -151,6 +185,13 @@ class Match: ModelObject, Storable { func next() -> Match? { Store.main.filter(isIncluded: { $0.round == round && $0.index == index + 1 }).first } + + func roundTitle() -> String? { + if groupStage != nil { return "Poule" } + else if let roundObject { return roundObject.roundTitle() } + else { return nil } + } + func topPreviousRoundMatchIndex() -> Int { index * 2 + 1 } @@ -274,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 } @@ -376,12 +423,12 @@ class Match: ModelObject, Storable { let endedSetsOne = teamScoreTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreTeam.isWalkOut()) let endedSetsTwo = teamScoreOtherTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreOtherTeam.isWalkOut()) var setDifference : Int = 0 - if endedSetsOne.count == 1 { + let zip = zip(endedSetsOne, endedSetsTwo) + if matchFormat.setsToWin == 1 { setDifference = endedSetsOne[0] - endedSetsTwo[0] } else { - setDifference = endedSetsOne.filter { $0 == matchFormat.setFormat.scoreToWin }.count - endedSetsTwo.filter { $0 == matchFormat.setFormat.scoreToWin }.count + setDifference = zip.filter { $0 > $1 }.count - zip.filter { $1 > $0 }.count } - let zip = zip(endedSetsOne, endedSetsTwo) let gameDifference = zip.map { ($0, $1) }.map { $0.0 - $0.1 }.reduce(0,+) return (setDifference * reverseValue, gameDifference * reverseValue) } 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 ba5f5be..c47641b 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -101,6 +101,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() @@ -142,7 +148,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 @@ -363,7 +370,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/Extensions/String+Extensions.swift b/PadelClub/Extensions/String+Extensions.swift index 10f3a0e..9eca94f 100644 --- a/PadelClub/Extensions/String+Extensions.swift +++ b/PadelClub/Extensions/String+Extensions.swift @@ -141,3 +141,12 @@ extension String { extension LosslessStringConvertible { var string: String { .init(self) } } + +extension String { + //april 04-2024 bug with accent characters / adobe / fft + mutating func replace(characters: [(Character, Character)]) { + for (targetChar, replacementChar) in characters { + self = String(self.map { $0 == targetChar ? replacementChar : $0 }) + } + } +} diff --git a/PadelClub/Manager/PadelRule.swift b/PadelClub/Manager/PadelRule.swift index cd48f06..cd55595 100644 --- a/PadelClub/Manager/PadelRule.swift +++ b/PadelClub/Manager/PadelRule.swift @@ -939,7 +939,7 @@ enum SetFormat: Int, Hashable, Codable { var firstGameFormat: Format { switch self { case .megaTieBreak: - return .tiebreakFiveTeen + return .tiebreakFifteen case .superTieBreak: return .tiebreakTen default: @@ -1243,7 +1243,7 @@ enum Format: Int, Hashable, Codable { case normal case tiebreakSeven case tiebreakTen - case tiebreakFiveTeen + case tiebreakFifteen func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { switch self { @@ -1253,7 +1253,7 @@ enum Format: Int, Hashable, Codable { return "tie-break en 7" case .tiebreakTen: return "tie-break en 10" - case .tiebreakFiveTeen: + case .tiebreakFifteen: return "tie-break en 15" } } @@ -1261,7 +1261,7 @@ enum Format: Int, Hashable, Codable { switch self { case .normal: return false - case .tiebreakSeven, .tiebreakTen, .tiebreakFiveTeen: + case .tiebreakSeven, .tiebreakTen, .tiebreakFifteen: return true } } @@ -1274,7 +1274,7 @@ enum Format: Int, Hashable, Codable { return 7 case .tiebreakTen: return 10 - case .tiebreakFiveTeen: + case .tiebreakFifteen: return 15 } } diff --git a/PadelClub/ViewModel/MatchScheduler.swift b/PadelClub/ViewModel/MatchScheduler.swift index 5778eca..3337951 100644 --- a/PadelClub/ViewModel/MatchScheduler.swift +++ b/PadelClub/ViewModel/MatchScheduler.swift @@ -6,19 +6,40 @@ // import Foundation +import LeStorage -struct TimeMatch { +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 groupLastRotation: [Int: Int] } extension Match { @@ -31,12 +52,44 @@ 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) -> MatchDispatcher { + 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.filter { startingDate == nil || $0.startDate == startingDate } + let _groupStages = groupStages // Get the maximum count of matches in any group let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 @@ -50,7 +103,7 @@ class MatchScheduler { } } - var slots = [TimeMatch]() + var slots = [GroupStageTimeMatch]() var availableMatchs = flattenedMatches var rotationIndex = 0 var teamsPerRotation = [Int: [String]]() @@ -81,7 +134,8 @@ class MatchScheduler { if let first = rotationMatches.first(where: { match in teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true }) { - slots.append(TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index )) + 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 }) @@ -96,10 +150,10 @@ class MatchScheduler { rotationIndex += 1 } - var organizedSlots = [TimeMatch]() + 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) -> Bool { - print(roundObject.roundTitle(), match.matchTitle()) + 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) if previousMatches.isEmpty { return true } @@ -135,73 +197,154 @@ class MatchScheduler { return false } - let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex + (roundObject.loser == nil ? 1 : 0) < rotationIndex }) - return previousMatchIsInPreviousRotation + 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 roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], randomizeCourts: Bool, initialOccupiedCourt: Int = 0) -> MatchDispatcher { - + 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]) -> [(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 freeCourtPerRotation = [Int: [Int]]() - var groupLastRotation = [Int: Int]() + var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) - while slots.count < flattenedMatches.count { + 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]]() + + let availableCourt = numberOfCourtsAvailablePerRotation + + var courts = initialCourts ?? (0.. 0 + + while availableMatchs.count > 0 { freeCourtPerRotation[rotationIndex] = [] - var matchPerRound = [Int: Int]() - var availableCourt = numberOfCourtsAvailablePerRotation - if rotationIndex == 0 { - availableCourt = availableCourt - initialOccupiedCourt + 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: true) + let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) - if let first = availableMatchs.first(where: { match in - let roundObject = match.roundObject! - let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex) - if roundObject.loser == nil && roundObject.index > 0, match.indexInRound() == 0, numberOfCourtsAvailablePerRotation > 1, let nextMatch = match.next() { - if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex) { - return true - } else { - return false - } - } - - if (matchPerRound[roundObject.index] ?? 0)%2 == 0 && roundObject.index != 0 && roundObject.loser == nil && courtIndex == numberOfCourtsAvailablePerRotation - 1 { - return false - } - - return canBePlayed - }) { - //print(first.roundObject!.roundTitle(), first.matchTitle()) - - if first.roundObject!.loser == nil { - if let roundIndex = matchPerRound[first.roundObject!.index] { - matchPerRound[first.roundObject!.index] = roundIndex + 1 - } else { - matchPerRound[first.roundObject!.index] = 1 - } + 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) + var difference = differenceWithBreak + if differenceWithBreak <= 0 { + difference = differenceWithoutBreak + } else if differenceWithBreak > timeDifferenceLimit && differenceWithoutBreak > timeDifferenceLimit { + difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak) } - slots.append(TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.roundObject!.index )) - availableMatchs.removeAll(where: { $0.id == first.id }) - if let index = first.roundObject?.index { - groupLastRotation[index] = rotationIndex + + if difference > timeDifferenceLimit { + courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index) + }) + freeCourtPerRotation[rotationIndex] = courts + courts = freeCourtPreviousRotation + rotationStartDate = rotationStartDate.addingTimeInterval(-difference) } - } else { - freeCourtPerRotation[rotationIndex]!.append(courtIndex) } } - + + dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation) rotationIndex += 1 } 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 + } else { + return false + } + } + + if currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.loser == nil && courtIndex == courts.count - 1 { + return false + } + + return canBePlayed + }) { + print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate) + + if first.roundObject!.loser == nil { + if let roundIndex = matchPerRound[first.roundObject!.index] { + matchPerRound[first.roundObject!.index] = roundIndex + 1 + } else { + matchPerRound[first.roundObject!.index] = 1 + } + } + let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.estimatedDuration, 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 { + freeCourtPerRotation[rotationIndex] = [] + let courtsUsed = getNextEarliestAvailableDate(from: slots) + let freeCourts = courtsUsed.filter { (courtIndex, availableDate) in + availableDate <= minimumTargetedEndDate + }.sorted(by: \.1).map { $0.0 } + + dispatchCourts(availableCourts: availableCourts, courts: freeCourts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: minimumTargetedEndDate, freeCourtPerRotation: &freeCourtPerRotation) + } } - 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 - if let roundId { - roundIndex = upperRounds.firstIndex(where: { $0.id == roundId }) ?? 0 - } - let rounds = upperRounds.flatMap { - [$0] + $0.loserRoundsAndChildren() + let rounds = upperRounds.map { + $0 + } + upperRounds.flatMap { + $0.loserRoundsAndChildren() } - 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, initialOccupiedCourt: 0) + 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 }) { - let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(match.matchFormat.estimatedDuration) * 60 - match.startDate = startDate.addingTimeInterval(timeIntervalToAdd) + match.startDate = matchSchedule.startDate match.setCourt(matchSchedule.courtIndex + 1) } } - try? DataStore.shared.matches.addOrUpdate(contentOfs: flattenedMatches) + try? DataStore.shared.matches.addOrUpdate(contentOfs: allMatches) } } diff --git a/PadelClub/ViewModel/NavigationViewModel.swift b/PadelClub/ViewModel/NavigationViewModel.swift index b39d960..70669d8 100644 --- a/PadelClub/ViewModel/NavigationViewModel.swift +++ b/PadelClub/ViewModel/NavigationViewModel.swift @@ -9,6 +9,7 @@ import SwiftUI @Observable class NavigationViewModel { + var path = NavigationPath() var agendaDestination: AgendaDestination? = .activity var tournament: Tournament? } diff --git a/PadelClub/ViewModel/SearchViewModel.swift b/PadelClub/ViewModel/SearchViewModel.swift index 5a03c11..6e4d80c 100644 --- a/PadelClub/ViewModel/SearchViewModel.swift +++ b/PadelClub/ViewModel/SearchViewModel.swift @@ -118,7 +118,7 @@ class SearchViewModel: ObservableObject, Identifiable { } func wordsPredicates() -> NSPredicate? { - let words = words().filter({ $0.isEmpty }) + let words = words().filter({ $0.isEmpty == false }) switch words.count { case 2: let predicates = [ 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/Navigation/Agenda/ActivityView.swift b/PadelClub/Views/Navigation/Agenda/ActivityView.swift index 9655ce0..3aec8bf 100644 --- a/PadelClub/Views/Navigation/Agenda/ActivityView.swift +++ b/PadelClub/Views/Navigation/Agenda/ActivityView.swift @@ -52,8 +52,8 @@ struct ActivityView: View { } var body: some View { - NavigationStack { - @Bindable var navigation = navigation + @Bindable var navigation = navigation + NavigationStack(path: $navigation.path) { VStack(spacing: 0) { GenericDestinationPickerView(selectedDestination: $navigation.agendaDestination, destinations: AgendaDestination.allCases, nilDestinationIsValid: false) List { @@ -187,16 +187,13 @@ struct ActivityView: View { TournamentView() .environment(tournament) } - .navigationDestination(item: $navigation.tournament) { tournament in - TournamentView() - .environment(tournament) - } } } } private func _gatherFederalTournaments() { isGatheringFederalTournaments = true + NetworkFederalService.shared.formId = "" Task { do { try await dataStore.clubs.filter { $0.code != nil }.concurrentForEach { club in diff --git a/PadelClub/Views/Navigation/Agenda/EventListView.swift b/PadelClub/Views/Navigation/Agenda/EventListView.swift index ee1df25..dc94862 100644 --- a/PadelClub/Views/Navigation/Agenda/EventListView.swift +++ b/PadelClub/Views/Navigation/Agenda/EventListView.swift @@ -29,7 +29,8 @@ struct EventListView: View { HStack { Text(section.monthYearFormatted) Spacer() - Text(_tournaments.map { $0.tournaments.count }.reduce(0,+).formatted()) + let count = _tournaments.map { $0.tournaments.count }.reduce(0,+) + Text("\(count.formatted()) tournoi" + count.pluralSuffix) } } .headerProminence(.increased) 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 d4a7aec..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,66 +72,40 @@ struct PlanningSettingsView: View { Text("Distribuer les terrains au hasard") } - RowButtonView("Horaire intelligent", role: .destructive) { - let groupStageCourtCount = tournament.groupStageCourtCount ?? 1 - let groupStages = tournament.groupStages() - let numberOfCourtsAvailablePerRotation: Int = min(tournament.courtCount, groupStageCourtCount * groupStages.count) - - let matchScheduler = MatchScheduler.shared - - let matches = tournament.groupStages().flatMap({ $0._matches() }) - matches.forEach({ $0.startDate = nil }) - - var times = Set(groupStages.compactMap { $0.startDate }) - if times.isEmpty { - groupStages.forEach({ $0.startDate = tournament.startDate }) - times.insert(tournament.startDate) - try? dataStore.groupStages.addOrUpdate(contentOfs: groupStages) - } + Toggle(isOn: $shouldHandleUpperRoundSlice) { + Text("Équilibrer les matchs d'une manche sur plusieurs tours") + } - var lastDate : Date? = nil - times.forEach { time in - let dispatch = matchScheduler.groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groupStages, startingDate: time, randomizeCourts: randomCourtDistribution) + 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") + } - dispatch.timedMatches.forEach { matchSchedule in - if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { - let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(match.matchFormat.estimatedDuration) * 60 - if let startDate = match.groupStageObject?.startDate { - match.startDate = startDate.addingTimeInterval(timeIntervalToAdd) - lastDate = match.startDate?.addingTimeInterval(Double(match.matchFormat.estimatedDuration) * 60) - } - match.setCourt(matchSchedule.courtIndex + 1) - } - } - } - try? dataStore.matches.addOrUpdate(contentOfs: matches) - let upperRounds = tournament.rounds() - let rounds = upperRounds.flatMap { - [$0] + $0.loserRoundsAndChildren() - } - - let flattenedMatches = rounds.flatMap { round in - round._matches().filter({ $0.disabled == false }).sorted(by: \.index) - } - - flattenedMatches.forEach({ $0.startDate = nil }) + LabeledContent { + StepperView(count: $upperBracketRotationDifference, minimum: 0, maximum: 2) + } label: { + Text("Tableau") + } + .disabled(rotationDifferenceIsImportant == false) - let roundDispatch = matchScheduler.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, randomizeCourts: randomCourtDistribution) + LabeledContent { + StepperView(count: $loserBracketRotationDifference, minimum: 0, maximum: 2) + } label: { + Text("Classement") + } + .disabled(rotationDifferenceIsImportant == false) - roundDispatch.timedMatches.forEach { matchSchedule in - if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) { - let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(match.matchFormat.estimatedDuration) * 60 - if let lastDate { - match.startDate = lastDate.addingTimeInterval(timeIntervalToAdd) - } - match.setCourt(matchSchedule.courtIndex + 1) - } - } - - try? dataStore.matches.addOrUpdate(contentOfs: flattenedMatches) + //timeDifferenceLimit - - scheduleSetup = true + RowButtonView("Horaire intelligent", role: .destructive) { + _setupSchedule() } if scheduleSetup { @@ -149,6 +141,74 @@ struct PlanningSettingsView: View { } } + private func _setupSchedule() { + let groupStageCourtCount = tournament.groupStageCourtCount ?? 1 + 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 }) + +// var times = Set(groupStages.compactMap { $0.startDate }.filter { $0 >= tournament.startDate } ) +// if times.isEmpty { +// groupStages.forEach({ $0.startDate = tournament.startDate }) +// times.insert(tournament.startDate) +// try? dataStore.groupStages.addOrUpdate(contentOfs: groupStages) +// } + + var lastDate : Date = tournament.startDate + groupStages.chunked(into: groupStageCourtCount).forEach { groups in + groups.forEach({ $0.startDate = lastDate }) + try? dataStore.groupStages.addOrUpdate(contentOfs: groups) + + 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 }) { + let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(match.matchFormat.estimatedDuration) * 60 + if let startDate = match.groupStageObject?.startDate { + let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) + match.startDate = matchStartDate + lastDate = matchStartDate.addingTimeInterval(Double(match.matchFormat.estimatedDuration) * 60) + } + match.setCourt(matchSchedule.courtIndex + 1) + } + } + } + try? dataStore.matches.addOrUpdate(contentOfs: matches) + + matchScheduler.updateSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) + + scheduleSetup = true + + } + private func _save() { try? dataStore.tournaments.addOrUpdate(instance: tournament) } diff --git a/PadelClub/Views/Planning/PlanningView.swift b/PadelClub/Views/Planning/PlanningView.swift index 37f83d7..6214d5b 100644 --- a/PadelClub/Views/Planning/PlanningView.swift +++ b/PadelClub/Views/Planning/PlanningView.swift @@ -74,7 +74,7 @@ struct PlanningView: View { ForEach(_matches) { match in LabeledContent { - + Text(match.matchFormat.format) } label: { if let groupStage = match.groupStageObject { Text(groupStage.groupStageTitle()) @@ -92,12 +92,18 @@ struct PlanningView: View { NavigationLink { MatchDetailView(match: match, matchViewStyle: .sectionedStandardStyle) } label: { - if let groupStage = match.groupStageObject { - Text(groupStage.groupStageTitle()) - } else if let round = match.roundObject { - Text(round.roundTitle()) + LabeledContent { + if let court = match.court { + Text(court) + } + } label: { + if let groupStage = match.groupStageObject { + Text(groupStage.groupStageTitle()) + } else if let round = match.roundObject { + Text(round.roundTitle()) + } + Text(match.matchTitle()) } - Text(match.matchTitle()) } } } label: { @@ -140,6 +146,7 @@ struct PlanningView: View { Text(matches.count.formatted() + " match" + matches.count.pluralSuffix) } label: { Text(key.formatted(date: .omitted, time: .shortened)).font(.largeTitle) + Text(Set(matches.compactMap { $0.roundTitle() }).joined(separator: ", ")) } } } diff --git a/PadelClub/Views/Planning/RoundScheduleEditorView.swift b/PadelClub/Views/Planning/RoundScheduleEditorView.swift index 5c07bdb..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,13 +36,18 @@ struct RoundScheduleEditorView: View { MatchScheduleEditorView(match: match) } } - .onChange(of: round.matchFormat) { - _save() - } } 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 dd7be35..ab4a982 100644 --- a/PadelClub/Views/Planning/SchedulerView.swift +++ b/PadelClub/Views/Planning/SchedulerView.swift @@ -7,24 +7,34 @@ import SwiftUI -extension GroupStage: Schedulable {} -extension Round: Schedulable {} +extension GroupStage: Schedulable { + func titleLabel() -> String { + self.groupStageTitle() + } +} +extension Round: Schedulable { + func titleLabel() -> String { + self.roundTitle() + } +} 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 round.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) @@ -42,12 +52,12 @@ struct SchedulerView: View { GroupStageScheduleEditorView(groupStage: groupStage) } } - .navigationTitle(schedulable.selectionLabel()) + .navigationTitle(schedulable.titleLabel()) } label: { LabeledContent { Text(schedulable.matchFormat.format).font(.largeTitle) } label: { - if let startDate = schedulable.startDate { + if let startDate = schedulable.getStartDate() { Text(startDate.formatted(.dateTime.hour().minute())).font(.largeTitle) Text(startDate.formatted(.dateTime.weekday().day(.twoDigits).month().year())) } else { @@ -55,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.selectionLabel()) + Text(schedulable.titleLabel()) } + .headerProminence(.increased) } } #Preview { - SchedulerView(tournament: Tournament.mock()) + SchedulerView(tournament: Tournament.mock(), destination: .scheduleBracket) } diff --git a/PadelClub/Views/Score/SetInputView.swift b/PadelClub/Views/Score/SetInputView.swift index 724c1b1..ebcea2f 100644 --- a/PadelClub/Views/Score/SetInputView.swift +++ b/PadelClub/Views/Score/SetInputView.swift @@ -95,7 +95,7 @@ struct SetInputView: View { return [6,5] } if valueTeamOne == 5 && setFormat == .four { - return [3,2] + return [4,3] } } return setFormat.possibleValues diff --git a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift index 4fccc36..93b3881 100644 --- a/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift +++ b/PadelClub/Views/Tournament/Screen/TournamentScheduleView.swift @@ -7,39 +7,34 @@ import SwiftUI -protocol Schedulable: Selectable, Identifiable { +protocol Schedulable: Identifiable { var startDate: Date? { get set } var matchFormat: MatchFormat { get set } func playedMatches() -> [Match] + func titleLabel() -> String } -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) - } +extension Schedulable { + func getStartDate() -> Date? { + startDate ?? playedMatches().first?.startDate } +} +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" } } @@ -52,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 { @@ -69,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/Shared/DateBoxView.swift b/PadelClub/Views/Tournament/Shared/DateBoxView.swift index f9377bf..1f03840 100644 --- a/PadelClub/Views/Tournament/Shared/DateBoxView.swift +++ b/PadelClub/Views/Tournament/Shared/DateBoxView.swift @@ -20,11 +20,12 @@ struct DateBoxView: View { .font(displayStyle == .wide ? .title : .title3) .monospacedDigit() } - Text(date.formatted(.dateTime.month(.abbreviated))) - .font(.caption2) - Text(date.formatted(.dateTime.year())) - .font(.caption2) - + if displayStyle == .wide { + Text(date.formatted(.dateTime.month(.abbreviated))) + .font(.caption2) + Text(date.formatted(.dateTime.year())) + .font(.caption2) + } } } } diff --git a/PadelClub/Views/Tournament/Shared/TournamentCellView.swift b/PadelClub/Views/Tournament/Shared/TournamentCellView.swift index b796d6b..a1b6e89 100644 --- a/PadelClub/Views/Tournament/Shared/TournamentCellView.swift +++ b/PadelClub/Views/Tournament/Shared/TournamentCellView.swift @@ -28,7 +28,7 @@ struct TournamentCellView: View { private func _buildView(_ build: any TournamentBuildHolder, existingTournament: Tournament?) -> some View { HStack { - DateBoxView(date: tournament.startDate, displayStyle: displayStyle) + DateBoxView(date: tournament.startDate, displayStyle: displayStyle == .wide ? .short : .wide) Rectangle() .fill(color) .frame(width: 2) @@ -56,7 +56,7 @@ struct TournamentCellView: View { Button { if let existingTournament { navigation.agendaDestination = .activity - navigation.tournament = existingTournament + navigation.path.append(existingTournament) } else { let event = federalTournament.getEvent() let newTournament = Tournament.newEmptyInstance() 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") + } } } }