add new feature : double group stages and loser bracket smart auto generation

sync2
Raz 1 year ago
parent 6ac26eb1e4
commit 13be596b26
  1. 14
      PadelClub/Data/GroupStage.swift
  2. 13
      PadelClub/Data/MatchScheduler.swift
  3. 3
      PadelClub/Data/Round.swift
  4. 21
      PadelClub/Data/Tournament.swift
  5. 8
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  6. 2
      PadelClub/Views/GroupStage/GroupStageView.swift
  7. 13
      PadelClub/Views/GroupStage/GroupStagesSettingsView.swift
  8. 43
      PadelClub/Views/GroupStage/LoserBracketFromGroupStageView.swift
  9. 2
      PadelClub/Views/Match/MatchSummaryView.swift
  10. 2
      PadelClub/Views/Planning/GroupStageScheduleEditorView.swift
  11. 2
      PadelClub/Views/Planning/PlanningSettingsView.swift
  12. 2
      PadelClub/Views/Planning/PlanningView.swift
  13. 4
      PadelClub/Views/Planning/SchedulerView.swift
  14. 2
      PadelClub/Views/Team/TeamRowView.swift
  15. 20
      PadelClub/Views/Tournament/Screen/TableStructureView.swift
  16. 8
      PadelClub/Views/Tournament/TournamentBuildView.swift

@ -71,14 +71,26 @@ final class GroupStage: ModelObject, Storable {
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let name { return name } if let name { return name }
var stepLabel = ""
if step > 0 {
stepLabel = " (" + (step + 1).ordinalFormatted(feminine: true) + " phase)"
}
switch displayStyle { switch displayStyle {
case .wide, .title: case .title:
return "Poule \(index + 1)" + stepLabel
case .wide:
return "Poule \(index + 1)" return "Poule \(index + 1)"
case .short: case .short:
return "#\(index + 1)" return "#\(index + 1)"
} }
} }
var computedOrder: Int {
index + step * 100
}
func isRunning() -> Bool { // at least a match has started func isRunning() -> Bool { // at least a match has started
_matches().anySatisfy({ $0.isRunning() }) _matches().anySatisfy({ $0.isRunning() })
} }

@ -93,9 +93,9 @@ final class MatchScheduler : ModelObject, Storable {
} }
@discardableResult @discardableResult
func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil) -> Date { func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date {
let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue() let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue()
var groupStages: [GroupStage] = tournament.groupStages() var groupStages: [GroupStage] = tournament.groupStages(atStep: step)
if let specificGroupStage { if let specificGroupStage {
groupStages = [specificGroupStage] groupStages = [specificGroupStage]
} }
@ -108,7 +108,7 @@ final class MatchScheduler : ModelObject, Storable {
$0.confirmed = false $0.confirmed = false
}) })
var lastDate : Date = tournament.startDate var lastDate : Date = startDate ?? tournament.startDate
let times = Set(groupStages.compactMap { $0.startDate }).sorted() let times = Set(groupStages.compactMap { $0.startDate }).sorted()
if let first = times.first { if let first = times.first {
@ -123,7 +123,9 @@ final class MatchScheduler : ModelObject, Storable {
} }
times.forEach({ time in times.forEach({ time in
lastDate = time if lastDate.isEarlierThan(time) {
lastDate = time
}
let groups = groupStages.filter({ $0.startDate == time }) let groups = groupStages.filter({ $0.startDate == time })
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate)
@ -743,6 +745,9 @@ final class MatchScheduler : ModelObject, Storable {
if tournament.groupStageCount > 0 { if tournament.groupStageCount > 0 {
lastDate = updateGroupStageSchedule(tournament: tournament) lastDate = updateGroupStageSchedule(tournament: tournament)
} }
if tournament.groupStages(atStep: 1).isEmpty == false {
lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate)
}
return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate)
} }
} }

@ -598,9 +598,6 @@ defer {
func deleteLoserBracket() { func deleteLoserBracket() {
do { do {
let loserRounds = loserRounds() let loserRounds = loserRounds()
for loserRound in loserRounds {
try loserRound.deleteDependencies()
}
try self.tournamentStore.rounds.delete(contentOfs: loserRounds) try self.tournamentStore.rounds.delete(contentOfs: loserRounds)
} catch { } catch {
Logger.error(error) Logger.error(error)

@ -385,6 +385,10 @@ final class Tournament : ModelObject, Storable {
return groupStages.sorted(by: \.index) return groupStages.sorted(by: \.index)
} }
func allGroupStages() -> [GroupStage] {
return self.tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder)
}
func allRounds() -> [Round] { func allRounds() -> [Round] {
return Array(self.tournamentStore.rounds) return Array(self.tournamentStore.rounds)
} }
@ -1757,7 +1761,7 @@ defer {
func deleteGroupStages() { func deleteGroupStages() {
do { do {
try self.tournamentStore.groupStages.delete(contentOfs: groupStages()) try self.tournamentStore.groupStages.delete(contentOfs: allGroupStages())
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
@ -2088,6 +2092,8 @@ defer {
Logger.error(error) Logger.error(error)
} }
} }
groupStages(atStep: 1).forEach { $0.buildMatches() }
} }
func lastStep() -> Int { func lastStep() -> Int {
@ -2095,11 +2101,24 @@ defer {
} }
func generateSmartLoserGroupStageBracket() { func generateSmartLoserGroupStageBracket() {
guard let groupStageLoserBracket = groupStageLoserBracket() else { return }
for i in qualifiedPerGroupStage..<teamsPerGroupStage { for i in qualifiedPerGroupStage..<teamsPerGroupStage {
groupStages().chunked(into: 2).forEach { gss in groupStages().chunked(into: 2).forEach { gss in
let placeCount = i * 2 + 1
let match = Match(round: groupStageLoserBracket.id, index: placeCount, matchFormat: groupStageLoserBracket.matchFormat)
match.name = "\(placeCount)\(placeCount.ordinalFormattedSuffix(feminine: true)) place"
do {
try tournamentStore.matches.addOrUpdate(instance: match)
} catch {
Logger.error(error)
}
if let gs1 = gss.first, let gs2 = gss.last, let score1 = gs1.teams(true)[safe: i], let score2 = gs2.teams(true)[safe: i] { if let gs1 = gss.first, let gs2 = gss.last, let score1 = gs1.teams(true)[safe: i], let score2 = gs2.teams(true)[safe: i] {
print("rang \(i)") print("rang \(i)")
print(score1.teamLabel(.short), "vs", score2.teamLabel(.short)) print(score1.teamLabel(.short), "vs", score2.teamLabel(.short))
match.setLuckyLoser(team: score1, teamPosition: .one)
match.setLuckyLoser(team: score2, teamPosition: .two)
} }
} }
} }

@ -8,15 +8,15 @@
import Foundation import Foundation
public extension FixedWidthInteger { public extension FixedWidthInteger {
func ordinalFormattedSuffix() -> String { func ordinalFormattedSuffix(feminine: Bool = false) -> String {
switch self { switch self {
case 1: return "er" case 1: return feminine ? "ère" : "er"
default: return "ème" default: return "ème"
} }
} }
func ordinalFormatted() -> String { func ordinalFormatted(feminine: Bool = false) -> String {
return self.formatted() + self.ordinalFormattedSuffix() return self.formatted() + self.ordinalFormattedSuffix(feminine: feminine)
} }
var pluralSuffix: String { var pluralSuffix: String {

@ -70,7 +70,7 @@ struct GroupStageView: View {
_groupStageMenuView() _groupStageMenuView()
} }
} }
.navigationTitle(groupStage.groupStageTitle()) .navigationTitle(groupStage.groupStageTitle(.title))
} }
private enum GroupStageSortingMode { private enum GroupStageSortingMode {

@ -10,7 +10,7 @@ import LeStorage
struct GroupStagesSettingsView: View { struct GroupStagesSettingsView: View {
@EnvironmentObject var dataStore: DataStore @EnvironmentObject var dataStore: DataStore
@Environment(\.dismiss) private var dismiss
@Environment(Tournament.self) var tournament: Tournament @Environment(Tournament.self) var tournament: Tournament
@State private var generationDone: Bool = false @State private var generationDone: Bool = false
let step: Int let step: Int
@ -89,7 +89,6 @@ struct GroupStagesSettingsView: View {
} else if let groupStageLoserBracket = tournament.groupStageLoserBracket() { } else if let groupStageLoserBracket = tournament.groupStageLoserBracket() {
RowButtonView("Supprimer les matchs de classements", role: .destructive) { RowButtonView("Supprimer les matchs de classements", role: .destructive) {
do { do {
try groupStageLoserBracket.deleteDependencies()
try tournamentStore.rounds.delete(instance: groupStageLoserBracket) try tournamentStore.rounds.delete(instance: groupStageLoserBracket)
} catch { } catch {
Logger.error(error) Logger.error(error)
@ -98,21 +97,25 @@ struct GroupStagesSettingsView: View {
} }
} }
if tournament.lastStep() == 0, step == 0, tournament.rounds().isEmpty { if tournament.lastStep() == 0, step == 0 {
Section { Section {
RowButtonView("Ajouter une phase de poule", role: .destructive) { RowButtonView("Ajouter une phase de poule", role: .destructive) {
tournament.addNewGroupStageStep() tournament.addNewGroupStageStep()
} }
} footer: {
Text("Padel Club peut vous créer une 2ème phase de poule utilisant les résultats de la première phase : les premiers de chaque poule joueront ensemble et ainsi de suite.")
} }
} else if step > 0 { } else if step > 0 {
Section { Section {
RowButtonView("Supprimer cette phase de poule", role: .destructive) { RowButtonView("Supprimer cette phase de poule", role: .destructive) {
let gs = tournament.groupStages(atStep: tournament.lastStep()) let groupStages = tournament.groupStages(atStep: tournament.lastStep())
do { do {
try tournament.tournamentStore.groupStages.delete(contentOfs: gs) try tournament.tournamentStore.groupStages.delete(contentOfs: groupStages)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
dismiss()
} }
} }

@ -33,9 +33,13 @@ struct LoserBracketFromGroupStageView: View {
List { List {
if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false { if isEditingLoserBracketGroupStage == true && displayableMatches.isEmpty == false {
Section { Section {
RowButtonView("Ajouter un match", role: .destructive) { _addButton()
_addNewMatch() }
}
Section {
_smartGenerationButton()
} footer: {
Text("La génération intelligente ajoutera un match par rang entre 2 poules. Si vos poules sont terminées, Padel Club placera les équipes automatiquement.")
} }
} }
@ -75,17 +79,10 @@ struct LoserBracketFromGroupStageView: View {
ContentUnavailableView { ContentUnavailableView {
Label("Aucun match de classement", systemImage: "figure.tennis") Label("Aucun match de classement", systemImage: "figure.tennis")
} description: { } description: {
Text("Vous n'avez créé aucun match de classement entre les perdants de poules.") Text("Vous n'avez créé aucun match de classement entre les perdants de poules. La génération intelligente ajoutera un match par rang entre 2 poules")
} actions: { } actions: {
RowButtonView("Ajouter un match") { _addButton()
isEditingLoserBracketGroupStage = true _smartGenerationButton()
_addNewMatch()
}
RowButtonView("Génération intelligente", role: .destructive) {
isEditingLoserBracketGroupStage = true
tournament.generateSmartLoserGroupStageBracket()
}
} }
} }
} }
@ -122,15 +119,28 @@ struct LoserBracketFromGroupStageView: View {
let displayableMatches = loserBracket.playedMatches().sorted(by: \.index) let displayableMatches = loserBracket.playedMatches().sorted(by: \.index)
do { do {
for match in displayableMatches {
try match.deleteDependencies()
}
try tournamentStore.matches.delete(contentOfs: displayableMatches) try tournamentStore.matches.delete(contentOfs: displayableMatches)
} catch { } catch {
Logger.error(error) Logger.error(error)
} }
} }
private func _smartGenerationButton() -> some View {
RowButtonView("Génération intelligente", role: .destructive, confirmationMessage: displayableMatches.isEmpty ? nil : "Les matchs de classement de poules déjà existants seront supprimés") {
isEditingLoserBracketGroupStage = true
_deleteAllMatches()
tournament.generateSmartLoserGroupStageBracket()
}
}
private func _addButton() -> some View {
RowButtonView("Ajouter un match") {
isEditingLoserBracketGroupStage = true
_addNewMatch()
}
}
} }
struct GroupStageLoserBracketMatchFooterView: View { struct GroupStageLoserBracketMatchFooterView: View {
@ -160,7 +170,6 @@ struct GroupStageLoserBracketMatchFooterView: View {
Spacer() Spacer()
FooterButtonView("Effacer", role: .destructive) { FooterButtonView("Effacer", role: .destructive) {
do { do {
try match.deleteDependencies()
try match.tournamentStore.matches.delete(instance: match) try match.tournamentStore.matches.delete(instance: match)
} catch { } catch {
Logger.error(error) Logger.error(error)

@ -28,7 +28,7 @@ struct MatchSummaryView: View {
self.color = Color(white: 0.9) self.color = Color(white: 0.9)
if let groupStage = match.groupStageObject { if let groupStage = match.groupStageObject {
self.roundTitle = groupStage.groupStageTitle() self.roundTitle = groupStage.groupStageTitle(.title)
} else if let round = match.roundObject { } else if let round = match.roundObject {
self.roundTitle = round.roundTitle(matchViewStyle == .feedStyle ? .wide : .short) self.roundTitle = round.roundTitle(matchViewStyle == .feedStyle ? .wide : .short)
} else { } else {

@ -27,7 +27,7 @@ struct GroupStageScheduleEditorView: View {
} }
var body: some View { var body: some View {
GroupStageDatePickingView(title: groupStage.groupStageTitle(), startDate: $startDate, currentDate: $groupStage.startDate, duration: groupStage.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)) { GroupStageDatePickingView(title: groupStage.groupStageTitle(.title), startDate: $startDate, currentDate: $groupStage.startDate, duration: groupStage.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)) {
groupStage.startDate = startDate groupStage.startDate = startDate
tournament.matchScheduler()?.updateGroupStageSchedule(tournament: tournament, specificGroupStage: groupStage) tournament.matchScheduler()?.updateGroupStageSchedule(tournament: tournament, specificGroupStage: groupStage)
_save() _save()

@ -114,7 +114,7 @@ struct PlanningSettingsView: View {
} }
let allMatches = tournament.allMatches() let allMatches = tournament.allMatches()
let allGroupStages = tournament.groupStages() let allGroupStages = tournament.allGroupStages()
let allRounds = tournament.allRounds() let allRounds = tournament.allRounds()
let matchesWithDate = allMatches.filter({ $0.startDate != nil }) let matchesWithDate = allMatches.filter({ $0.startDate != nil })
let groupStagesWithDate = allGroupStages.filter({ $0.startDate != nil }) let groupStagesWithDate = allGroupStages.filter({ $0.startDate != nil })

@ -98,7 +98,7 @@ struct PlanningView: View {
} }
} label: { } label: {
if let groupStage = match.groupStageObject { if let groupStage = match.groupStageObject {
Text(groupStage.groupStageTitle()) Text(groupStage.groupStageTitle(.title))
} else if let round = match.roundObject { } else if let round = match.roundObject {
Text(round.roundTitle()) Text(round.roundTitle())
} }

@ -43,7 +43,7 @@ struct SchedulerView: View {
} }
} }
.onChange(of: tournament.groupStageMatchFormat) { .onChange(of: tournament.groupStageMatchFormat) {
let groupStages = tournament.groupStages() let groupStages = tournament.allGroupStages()
groupStages.forEach { groupStage in groupStages.forEach { groupStage in
groupStage.updateMatchFormat(tournament.groupStageMatchFormat) groupStage.updateMatchFormat(tournament.groupStageMatchFormat)
} }
@ -68,7 +68,7 @@ struct SchedulerView: View {
} }
} }
ForEach(tournament.groupStages()) { ForEach(tournament.allGroupStages()) {
GroupStageScheduleEditorView(groupStage: $0, tournament: tournament) GroupStageScheduleEditorView(groupStage: $0, tournament: tournament)
.id(UUID()) .id(UUID())
} }

@ -19,7 +19,7 @@ struct TeamRowView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
if let groupStage = team.groupStageObject() { if let groupStage = team.groupStageObject() {
HStack { HStack {
Text(groupStage.groupStageTitle()) Text(groupStage.groupStageTitle(.title))
if let finalPosition = groupStage.finalPosition(ofTeam: team) { if let finalPosition = groupStage.finalPosition(ofTeam: team) {
Text((finalPosition + 1).ordinalFormatted()) Text((finalPosition + 1).ordinalFormatted())
} }

@ -174,13 +174,13 @@ struct TableStructureView: View {
Section { Section {
let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0) let tf = max(teamCount - teamsFromGroupStages + qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0), 0)
if groupStageCount > 0 { if groupStageCount > 0 {
LabeledContent {
Text(teamsFromGroupStages.formatted())
} label: {
Text("Équipes en poule")
}
if structurePreset == .manual { if structurePreset == .manual {
LabeledContent {
Text(teamsFromGroupStages.formatted())
} label: {
Text("Équipes en poule")
}
LabeledContent { LabeledContent {
Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted()) Text((qualifiedFromGroupStage + (groupStageCount > 0 ? groupStageAdditionalQualified : 0)).formatted())
@ -203,6 +203,14 @@ struct TableStructureView: View {
} label: { } label: {
Text("Équipes en tableau final") Text("Équipes en tableau final")
} }
} else {
LabeledContent {
let mp1 = teamsPerGroupStage * (teamsPerGroupStage - 1) / 2 * groupStageCount
let mp2 = (groupStageCount * (groupStageCount - 1) / 2) * teamsPerGroupStage
Text((mp1 + mp2).formatted())
} label: {
Text("Total de matchs")
}
} }
} }

@ -30,7 +30,11 @@ struct TournamentBuildView: View {
ProgressView() ProgressView()
} }
} label: { } label: {
Text("Poules") if tournament.groupStages(atStep: 1).isEmpty == false {
Text("1ère phase de poules")
} else {
Text("Poules")
}
if tournament.groupStagesAreOver(), tournament.moreQualifiedToDraw() > 0 { if tournament.groupStagesAreOver(), tournament.moreQualifiedToDraw() > 0 {
let moreQualifiedToDraw = tournament.moreQualifiedToDraw() let moreQualifiedToDraw = tournament.moreQualifiedToDraw()
Text("Qualifié\(moreQualifiedToDraw.pluralSuffix) sortant\(moreQualifiedToDraw.pluralSuffix) manquant\(moreQualifiedToDraw.pluralSuffix)").foregroundStyle(.logoRed) Text("Qualifié\(moreQualifiedToDraw.pluralSuffix) sortant\(moreQualifiedToDraw.pluralSuffix) manquant\(moreQualifiedToDraw.pluralSuffix)").foregroundStyle(.logoRed)
@ -56,7 +60,7 @@ struct TournamentBuildView: View {
} }
if tournament.groupStages(atStep: 1).isEmpty == false { if tournament.groupStages(atStep: 1).isEmpty == false {
NavigationLink("Step 1") { NavigationLink("2ème phase de poules") {
GroupStagesView(tournament: tournament, step: 1) GroupStagesView(tournament: tournament, step: 1)
} }
} }

Loading…
Cancel
Save