diff --git a/PadelClub/Data/GroupStage.swift b/PadelClub/Data/GroupStage.swift index 32e07b2..ee20f67 100644 --- a/PadelClub/Data/GroupStage.swift +++ b/PadelClub/Data/GroupStage.swift @@ -24,6 +24,7 @@ final class GroupStage: ModelObject, Storable { private var format: MatchFormat? var startDate: Date? var name: String? + var step: Int = 0 var matchFormat: MatchFormat { get { @@ -34,13 +35,14 @@ final class GroupStage: ModelObject, Storable { } } - internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil) { + internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil, step: Int = 0) { self.tournament = tournament self.index = index self.size = size self.format = matchFormat self.startDate = startDate self.name = name + self.step = step } var tournamentStore: TournamentStore { @@ -61,7 +63,10 @@ final class GroupStage: ModelObject, Storable { // MARK: - func teamAt(groupStagePosition: Int) -> TeamRegistration? { - teams().first(where: { $0.groupStagePosition == groupStagePosition }) + if step > 0 { + return teams().first(where: { $0.groupStagePositionAtStep(step) == groupStagePosition }) + } + return teams().first(where: { $0.groupStagePosition == groupStagePosition }) } func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { @@ -190,7 +195,7 @@ final class GroupStage: ModelObject, Storable { } func initialStartDate(forTeam team: TeamRegistration) -> Date? { - guard let groupStagePosition = team.groupStagePosition else { return nil } + guard let groupStagePosition = team.groupStagePositionAtStep(step) else { return nil } return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate } @@ -326,6 +331,9 @@ final class GroupStage: ModelObject, Storable { } func unsortedTeams() -> [TeamRegistration] { + if step > 0 { + return self.tournamentStore.groupStages.filter({ $0.step == step - 1 }).compactMap({ $0.teams(true)[safe: index] }) + } return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil } } @@ -397,6 +405,19 @@ final class GroupStage: ModelObject, Storable { self.tournamentStore.matches.deleteDependencies(matches) } + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: ._id) + tournament = try container.decode(String.self, forKey: ._tournament) + index = try container.decode(Int.self, forKey: ._index) + size = try container.decode(Int.self, forKey: ._size) + format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) + startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) + name = try container.decodeIfPresent(String.self, forKey: ._name) + step = try container.decodeIfPresent(Int.self, forKey: ._step) ?? 0 + } + func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) @@ -422,6 +443,8 @@ final class GroupStage: ModelObject, Storable { } else { try container.encodeNil(forKey: ._name) } + + try container.encode(step, forKey: ._step) } func insertOnServer() { @@ -442,6 +465,7 @@ extension GroupStage { case _format = "format" case _startDate = "startDate" case _name = "name" + case _step = "step" } } diff --git a/PadelClub/Data/TeamRegistration.swift b/PadelClub/Data/TeamRegistration.swift index 1c90fef..3ed4dc1 100644 --- a/PadelClub/Data/TeamRegistration.swift +++ b/PadelClub/Data/TeamRegistration.swift @@ -511,6 +511,16 @@ final class TeamRegistration: ModelObject, Storable { return Store.main.findById(tournament) } + func groupStagePositionAtStep(_ step: Int) -> Int? { + guard let groupStagePosition else { return nil } + if step == 0 { + return groupStagePosition + } else if let groupStageObject = groupStageObject(), groupStageObject.hasEnded() { + return groupStageObject.index + } + return nil + } + enum CodingKeys: String, CodingKey { case _id = "id" case _tournament = "tournament" diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift index cab2239..160911a 100644 --- a/PadelClub/Data/Tournament.swift +++ b/PadelClub/Data/Tournament.swift @@ -380,15 +380,11 @@ final class Tournament : ModelObject, Storable { return Array(self.tournamentStore.teamRegistrations) } - func groupStages() -> [GroupStage] { - let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id } + func groupStages(atStep step: Int = 0) -> [GroupStage] { + let groupStages: [GroupStage] = self.tournamentStore.groupStages.filter { $0.tournament == self.id && $0.step == step } return groupStages.sorted(by: \.index) } - func allGroupStages() -> [GroupStage] { - return Array(self.tournamentStore.groupStages) - } - func allRounds() -> [Round] { return Array(self.tournamentStore.rounds) } @@ -767,8 +763,8 @@ defer { closedRegistrationDate != nil } - func getActiveGroupStage() -> GroupStage? { - let groupStages = groupStages() + func getActiveGroupStage(atStep step: Int = 0) -> GroupStage? { + let groupStages = groupStages(atStep: step) return groupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).first ?? groupStages.first } @@ -1118,8 +1114,8 @@ defer { return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame } - func groupStagesMatches() -> [Match] { - return self.tournamentStore.matches.filter { $0.groupStage != nil } + func groupStagesMatches(atStep step: Int = 0) -> [Match] { + return groupStages(atStep: step).flatMap({ $0._matches() }) // return Store.main.filter(isIncluded: { $0.groupStage != nil && groupStageIds.contains($0.groupStage!) }) } @@ -1191,6 +1187,19 @@ defer { var teams: [Int: [String]] = [:] var ids: Set = Set() let rounds = rounds() + let lastStep = lastStep() + if rounds.isEmpty, lastStep > 0 { + let groupStages = groupStages(atStep: lastStep) + + for groupStage in groupStages { + for (teamIndex, team) in groupStage.teams(true).enumerated() { + teams[groupStage.index + 1 + teamIndex] = [team.id] + } + } + + return teams + } + let final = rounds.last?.playedMatches().last if let winner = final?.winningTeamId { teams[1] = [winner] @@ -1600,12 +1609,23 @@ defer { return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") } - func deleteAndBuildEverything() { + func deleteAndBuildEverything(preset: PadelTournamentStructurePreset = .manual) { resetBracketPosition() deleteStructure() deleteGroupStages() - buildGroupStages() - buildBracket() + + switch preset { + case .manual: + buildGroupStages() + buildBracket() + case .doubleGroupStage: + buildGroupStages() + addNewGroupStageStep() + + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + + } } func buildGroupStages() { @@ -2021,6 +2041,22 @@ defer { return teamCount - teamsPerGroupStage * groupStageCount + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified + 1 } + func addNewGroupStageStep() { + let lastStep = lastStep() + 1 + for i in 0.. Int { + self.tournamentStore.groupStages.sorted(by: \.step).last?.step ?? 0 + } + // MARK: - func insertOnServer() throws { diff --git a/PadelClub/Utils/PadelRule.swift b/PadelClub/Utils/PadelRule.swift index 7e6f922..af5177c 100644 --- a/PadelClub/Utils/PadelRule.swift +++ b/PadelClub/Utils/PadelRule.swift @@ -1637,3 +1637,27 @@ enum AnimationType: Int, CaseIterable, Hashable, Identifiable { } } +enum PadelTournamentStructurePreset: Int, Identifiable, CaseIterable { + var id: Int { self.rawValue } + + case manual + case doubleGroupStage + + func localizedStructurePresetTitle() -> String { + switch self { + case .manual: + return "Défaut" + case .doubleGroupStage: + return "2 phases de poules" + } + } + + func localizedDescriptionStructurePresetTitle() -> String { + switch self { + case .manual: + return "24 équipes, 4 poules de 4, 1 qualifié par poule" + case .doubleGroupStage: + return "Poules qui enchaîne sur une autre phase de poule : les premiers de chaque se retrouve ensemble, puis les 2èmes, etc." + } + } +} diff --git a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift index dfffa60..65ecfc3 100644 --- a/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift +++ b/PadelClub/Views/Cashier/Event/TournamentConfiguratorView.swift @@ -40,7 +40,7 @@ struct TournamentConfigurationView: View { } Picker(selection: $tournament.federalCategory, label: Text("Catégorie")) { ForEach(TournamentCategory.allCases) { type in - Text(type.localizedLabel(.wide)).tag(type) + Text(type.localizedLabel(.title)).tag(type) } } Picker(selection: $tournament.federalAgeCategory, label: Text("Limite d'âge")) { diff --git a/PadelClub/Views/GroupStage/GroupStageView.swift b/PadelClub/Views/GroupStage/GroupStageView.swift index 5b04f71..467a377 100644 --- a/PadelClub/Views/GroupStage/GroupStageView.swift +++ b/PadelClub/Views/GroupStage/GroupStageView.swift @@ -105,7 +105,7 @@ struct GroupStageView: View { var body: some View { ForEach(0..<(groupStage.size), id: \.self) { index in - if let team = _teamAt(atIndex: index), let groupStagePosition = team.groupStagePosition { + if let team = _teamAt(atIndex: index), let groupStagePosition = team.groupStagePositionAtStep(groupStage.step) { NavigationLink { GroupStageTeamView(groupStage: groupStage, team: team) .environment(self.tournament) @@ -137,7 +137,7 @@ struct GroupStageView: View { } } Spacer() - if let score = groupStage.scoreLabel(forGroupStagePosition: groupStagePosition, score: scores?.first(where: { $0.team.groupStagePosition == groupStagePosition })) { + if let score = groupStage.scoreLabel(forGroupStagePosition: groupStagePosition, score: scores?.first(where: { $0.team.groupStagePositionAtStep(groupStage.step) == groupStagePosition })) { VStack(alignment: .trailing) { HStack(spacing: 0.0) { Text(score.wins) diff --git a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift index e0f29dc..adf1426 100644 --- a/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift +++ b/PadelClub/Views/GroupStage/GroupStagesSettingsView.swift @@ -13,6 +13,7 @@ struct GroupStagesSettingsView: View { @Environment(Tournament.self) var tournament: Tournament @State private var generationDone: Bool = false + let step: Int var tournamentStore: TournamentStore { return self.tournament.tournamentStore @@ -96,6 +97,26 @@ struct GroupStagesSettingsView: View { } } } + + if tournament.lastStep() == 0, step == 0, tournament.rounds().isEmpty { + Section { + RowButtonView("Ajouter une phase de poule", role: .destructive) { + tournament.addNewGroupStageStep() + } + } + } else if step > 0 { + Section { + RowButtonView("Supprimer cette phase de poule", role: .destructive) { + let gs = tournament.groupStages(atStep: tournament.lastStep()) + do { + try tournament.tournamentStore.groupStages.delete(contentOfs: gs) + } catch { + Logger.error(error) + } + } + } + + } #if DEBUG Section { diff --git a/PadelClub/Views/GroupStage/GroupStagesView.swift b/PadelClub/Views/GroupStage/GroupStagesView.swift index 7274171..6f3355d 100644 --- a/PadelClub/Views/GroupStage/GroupStagesView.swift +++ b/PadelClub/Views/GroupStage/GroupStagesView.swift @@ -12,6 +12,7 @@ struct GroupStagesView: View { @State var tournament: Tournament @State private var selectedDestination: GroupStageDestination? @EnvironmentObject var dataStore: DataStore + let step: Int enum GroupStageDestination: Selectable, Identifiable, Equatable { static func == (lhs: GroupStagesView.GroupStageDestination, rhs: GroupStagesView.GroupStageDestination) -> Bool { @@ -77,17 +78,18 @@ struct GroupStagesView: View { } var allMatches: [Match] { - tournament.groupStagesMatches() + tournament.groupStagesMatches(atStep: step) } - init(tournament: Tournament) { + init(tournament: Tournament, step: Int = 0) { self.tournament = tournament + self.step = step if tournament.shouldVerifyGroupStage { _selectedDestination = State(wrappedValue: nil) } else if tournament.unsortedTeams().filter({ $0.groupStagePosition != nil }).isEmpty { _selectedDestination = State(wrappedValue: nil) } else { - let gs = tournament.getActiveGroupStage() + let gs = tournament.getActiveGroupStage(atStep: step) if let gs { _selectedDestination = State(wrappedValue: .groupStage(gs)) } @@ -96,7 +98,7 @@ struct GroupStagesView: View { func allDestinations() -> [GroupStageDestination] { var allDestinations : [GroupStageDestination] = [.all(tournament)] - let groupStageDestinations : [GroupStageDestination] = tournament.groupStages().map { GroupStageDestination.groupStage($0) } + let groupStageDestinations : [GroupStageDestination] = tournament.groupStages(atStep: step).map { GroupStageDestination.groupStage($0) } if let loserBracket = tournament.groupStageLoserBracket() { allDestinations.insert(.loserBracket(loserBracket), at: 0) } @@ -158,10 +160,11 @@ struct GroupStagesView: View { case .loserBracket(let loserBracket): LoserBracketFromGroupStageView(loserBracket: loserBracket).id(loserBracket.id) case nil: - GroupStagesSettingsView() + GroupStagesSettingsView(step: step) .navigationTitle("Réglages") } } + .environment(tournament) .navigationBarTitleDisplayMode(.inline) .toolbarBackground(.visible, for: .navigationBar) } diff --git a/PadelClub/Views/Tournament/Screen/TableStructureView.swift b/PadelClub/Views/Tournament/Screen/TableStructureView.swift index 6ca1e6c..b33820c 100644 --- a/PadelClub/Views/Tournament/Screen/TableStructureView.swift +++ b/PadelClub/Views/Tournament/Screen/TableStructureView.swift @@ -19,8 +19,9 @@ struct TableStructureView: View { @State private var qualifiedPerGroupStage: Int = 0 @State private var groupStageAdditionalQualified: Int = 0 @State private var updatedElements: Set = Set() + @State private var structurePreset: PadelTournamentStructurePreset = .manual @FocusState private var stepperFieldIsFocused: Bool - + var qualifiedFromGroupStage: Int { groupStageCount * qualifiedPerGroupStage } @@ -51,6 +52,37 @@ struct TableStructureView: View { @ViewBuilder var body: some View { List { + + if tournament.state() != .build { + Section { + Picker(selection: $structurePreset) { + ForEach(PadelTournamentStructurePreset.allCases) { preset in + Text(preset.localizedStructurePresetTitle()).tag(preset) + } + } label: { + Text("Préréglage") + } + } footer: { + Text(structurePreset.localizedDescriptionStructurePresetTitle()) + } + .onChange(of: structurePreset) { + switch structurePreset { + case .manual: + teamCount = 24 + groupStageCount = 4 + teamsPerGroupStage = 4 + qualifiedPerGroupStage = 1 + groupStageAdditionalQualified = 0 + case .doubleGroupStage: + teamCount = 9 + groupStageCount = 3 + teamsPerGroupStage = 3 + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + } + } + } + Section { LabeledContent { StepperView(count: $teamCount, minimum: 4, maximum: 128) @@ -73,33 +105,62 @@ struct TableStructureView: View { Text("Équipes par poule") } - LabeledContent { - StepperView(count: $qualifiedPerGroupStage, minimum: 1, maximum: (teamsPerGroupStage-1)) - } label: { - Text("Qualifiés par poule") - } - - if qualifiedPerGroupStage < teamsPerGroupStage - 1 { + if structurePreset == .manual { LabeledContent { - StepperView(count: $groupStageAdditionalQualified, minimum: 0, maximum: maxMoreQualified) + StepperView(count: $qualifiedPerGroupStage, minimum: 1, maximum: (teamsPerGroupStage-1)) } label: { - Text("Qualifiés supplémentaires") - Text(moreQualifiedLabel) + Text("Qualifiés par poule") } - .onChange(of: groupStageAdditionalQualified) { - if groupStageAdditionalQualified == groupStageCount { - qualifiedPerGroupStage += 1 - groupStageAdditionalQualified -= groupStageCount + + if qualifiedPerGroupStage < teamsPerGroupStage - 1 { + LabeledContent { + StepperView(count: $groupStageAdditionalQualified, minimum: 0, maximum: maxMoreQualified) + } label: { + Text("Qualifiés supplémentaires") + Text(moreQualifiedLabel) + } + .onChange(of: groupStageAdditionalQualified) { + if groupStageAdditionalQualified == groupStageCount { + qualifiedPerGroupStage += 1 + groupStageAdditionalQualified -= groupStageCount + } } } } if groupStageCount > 0 && teamsPerGroupStage > 0 { - LabeledContent { - let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 - Text(mp.formatted()) - } label: { - Text("Matchs à jouer par poule") + if structurePreset == .manual { + LabeledContent { + let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + } + } else { + LabeledContent { + let mp = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + Text("Première phase") + } + + LabeledContent { + let mp = (groupStageCount * (groupStageCount - 1) / 2) + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par poule") + Text("Deuxième phase") + } + + LabeledContent { + let mp = groupStageCount - 1 + teamsPerGroupStage - 1 + Text(mp.formatted()) + } label: { + Text("Matchs à jouer par équipe") + Text("Total") + } + } } } @@ -116,22 +177,30 @@ struct TableStructureView: View { } label: { Text("Équipes en poule") } + + if structurePreset == .manual { + + LabeledContent { + Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) + } label: { + Text("Équipes qualifiées de poule") + } + } + } + + if structurePreset == .manual { + LabeledContent { - Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) + let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0) + Text(tsPure.formatted()) } label: { - Text("Équipes qualifiées de poule") + Text("Nombre de têtes de série") + } + LabeledContent { + Text(tf.formatted()) + } label: { + Text("Équipes en tableau final") } - } - LabeledContent { - let tsPure = max(teamCount - groupStageCount * teamsPerGroupStage, 0) - Text(tsPure.formatted()) - } label: { - Text("Nombre de têtes de série") - } - LabeledContent { - Text(tf.formatted()) - } label: { - Text("Équipes en tableau final") } } @@ -154,6 +223,13 @@ struct TableStructureView: View { _save(rebuildEverything: true) } } + + Section { + RowButtonView("Remise-à-zéro", role: .destructive) { + tournament.deleteGroupStages() + tournament.deleteStructure() + } + } } } .focused($stepperFieldIsFocused) @@ -283,7 +359,7 @@ struct TableStructureView: View { tournament.groupStageAdditionalQualified = groupStageAdditionalQualified if rebuildEverything { - tournament.deleteAndBuildEverything() + tournament.deleteAndBuildEverything(preset: structurePreset) } else if (rebuildEverything == false && requirements.contains(.groupStage)) { tournament.deleteGroupStages() tournament.buildGroupStages() diff --git a/PadelClub/Views/Tournament/TournamentBuildView.swift b/PadelClub/Views/Tournament/TournamentBuildView.swift index a11e055..75c2cdd 100644 --- a/PadelClub/Views/Tournament/TournamentBuildView.swift +++ b/PadelClub/Views/Tournament/TournamentBuildView.swift @@ -55,6 +55,12 @@ struct TournamentBuildView: View { } } + if tournament.groupStages(atStep: 1).isEmpty == false { + NavigationLink("Step 1") { + GroupStagesView(tournament: tournament, step: 1) + } + } + if tournament.rounds().isEmpty == false { NavigationLink(value: Screen.round) { LabeledContent {