add loser bracket management

multistore
Razmig Sarkissian 2 years ago
parent ff0b236afc
commit db9d4637d0
  1. 8
      PadelClub.xcodeproj/project.pbxproj
  2. 14
      PadelClub/Data/Match.swift
  3. 110
      PadelClub/Data/Round.swift
  4. 13
      PadelClub/Data/Tournament.swift
  5. 53
      PadelClub/Views/Round/LoserBracketView.swift
  6. 65
      PadelClub/Views/Round/LoserRoundsView.swift
  7. 19
      PadelClub/Views/Round/RoundSettingsView.swift
  8. 12
      PadelClub/Views/Round/RoundView.swift

@ -178,6 +178,8 @@
FFC1E1082BAC29FC008D6F59 /* LocationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */; };
FFC1E10A2BAC2A77008D6F59 /* NetworkFederalService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E1092BAC2A77008D6F59 /* NetworkFederalService.swift */; };
FFC1E10C2BAC7FB0008D6F59 /* ClubImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC1E10B2BAC7FB0008D6F59 /* ClubImportView.swift */; };
FFC2DCB22BBE75D40046DB9F /* LoserBracketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */; };
FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */; };
FFC83D4F2BB807D100750834 /* RoundsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC83D4E2BB807D100750834 /* RoundsView.swift */; };
FFC83D512BB8087E00750834 /* RoundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFC83D502BB8087E00750834 /* RoundView.swift */; };
FFCFBFFE2BBBE86600B82851 /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = FFCFBFFD2BBBE86600B82851 /* Algorithms */; };
@ -422,6 +424,8 @@
FFC1E1072BAC29FC008D6F59 /* LocationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationManager.swift; sourceTree = "<group>"; };
FFC1E1092BAC2A77008D6F59 /* NetworkFederalService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkFederalService.swift; sourceTree = "<group>"; };
FFC1E10B2BAC7FB0008D6F59 /* ClubImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClubImportView.swift; sourceTree = "<group>"; };
FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserBracketView.swift; sourceTree = "<group>"; };
FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoserRoundsView.swift; sourceTree = "<group>"; };
FFC83D4E2BB807D100750834 /* RoundsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundsView.swift; sourceTree = "<group>"; };
FFC83D502BB8087E00750834 /* RoundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundView.swift; sourceTree = "<group>"; };
FFCFC0012BBC39A600B82851 /* EditScoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditScoreView.swift; sourceTree = "<group>"; };
@ -925,6 +929,8 @@
FFC83D4E2BB807D100750834 /* RoundsView.swift */,
FFC83D502BB8087E00750834 /* RoundView.swift */,
FF5DA1922BB9279B00A33061 /* RoundSettingsView.swift */,
FFC2DCB12BBE75D40046DB9F /* LoserBracketView.swift */,
FFC2DCB32BBE9ECD0046DB9F /* LoserRoundsView.swift */,
);
path = Round;
sourceTree = "<group>";
@ -1275,6 +1281,7 @@
FF6EC90B2B947AC000EA7F5A /* Array+Extensions.swift in Sources */,
FF59FFB92B90EFD70061EFF9 /* ToolboxView.swift in Sources */,
FFF8ACD92B923F3C008466FA /* String+Extensions.swift in Sources */,
FFC2DCB22BBE75D40046DB9F /* LoserBracketView.swift in Sources */,
FFA6D7852BB0B795003A31F3 /* FileImportManager.swift in Sources */,
FF6EC8FB2B94788600EA7F5A /* TournamentButtonView.swift in Sources */,
FFF8ACCD2B92367B008466FA /* FederalPlayer.swift in Sources */,
@ -1293,6 +1300,7 @@
FFA6D7872BB0B7A2003A31F3 /* CloudConvert.swift in Sources */,
FF1DC55B2BAB80C400FD8220 /* DisplayContext.swift in Sources */,
C425D4032B6D249D002A7B48 /* ContentView.swift in Sources */,
FFC2DCB42BBE9ECD0046DB9F /* LoserRoundsView.swift in Sources */,
FF967CFC2BAEE52E00A9A3BD /* GroupStagesView.swift in Sources */,
FFD783FF2B91BA42000F62A6 /* PadelClubView.swift in Sources */,
FFCFC0142BBC59FC00B82851 /* MatchDescriptor.swift in Sources */,

@ -318,18 +318,22 @@ class Match: ModelObject, Storable {
}
func roundProjectedTeam(_ team: TeamData) -> TeamRegistration? {
guard round != nil else { return nil }
if let seed = seed(team) {
guard let roundObject else { return nil }
if roundObject.isLoserBracket() == false, let seed = seed(team) {
return seed
}
let indexInRound = indexInRound()
switch team {
case .one:
if let teamId = topPreviousRoundMatch()?.winningTeamId {
if roundObject.isLoserBracket(), roundObject.previousRound() == nil, let parentRound = roundObject.parentRound, let loser = parentRound.matches.first(where: { parentRound.indexOfMatch($0) == indexInRound * 2 })?.losingTeamId {
return Store.main.findById(loser)
} else if let teamId = topPreviousRoundMatch()?.winningTeamId {
return Store.main.findById(teamId)
}
case .two:
if let teamId = bottomPreviousRoundMatch()?.winningTeamId {
if roundObject.isLoserBracket(), roundObject.previousRound() == nil, let parentRound = roundObject.parentRound, let loser = parentRound.matches.first(where: { parentRound.indexOfMatch($0) == indexInRound * 2 + 1 })?.losingTeamId {
return Store.main.findById(loser)
} else if let teamId = bottomPreviousRoundMatch()?.winningTeamId {
return Store.main.findById(teamId)
}
}

@ -52,15 +52,47 @@ class Round: ModelObject, Storable {
}
func previousRound() -> Round? {
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.index == index + 1 }).first
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.loser == loser && $0.index == index + 1 }).first
}
func nextRound() -> Round? {
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.index == index - 1 }).first
Store.main.filter(isIncluded: { $0.tournament == tournament && $0.loser == loser && $0.index == index - 1 }).first
}
func loserRounds(forRoundIndex roundIndex: Int) -> [Round] {
return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.cumulativeMatchCount)
}
func getActiveLoserRound() -> Round? {
let rounds = loserRounds()
return rounds.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).reversed().first ?? rounds.first
}
var cumulativeMatchCount: Int {
var totalMatches = matches.count
if let parent = parentRound {
totalMatches += parent.cumulativeMatchCount
}
return totalMatches
}
func initialRound() -> Round? {
if let parentRound {
return parentRound.initialRound()
} else {
return self
}
}
func roundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
RoundRule.roundName(fromRoundIndex: index)
if let parentRound, let initialRound = parentRound.initialRound() {
let parentMatchCount = parentRound.cumulativeMatchCount - initialRound.matches.count
print("initialRound", initialRound.roundTitle())
if let initialRoundNextRound = initialRound.nextRound()?.matches {
return SeedInterval(first: parentMatchCount + initialRoundNextRound.count * 2 + 1, last: parentMatchCount + initialRoundNextRound.count * 2 + matches.count * 2).localizedLabel(displayStyle)
}
}
return RoundRule.roundName(fromRoundIndex: index)
}
func roundStatus() -> String {
@ -71,16 +103,64 @@ class Round: ModelObject, Storable {
}
}
var loserRound: Round? {
guard let loser else { return nil }
return Store.main.findById(loser)
func indexOfMatch(_ match: Match) -> Int? {
matches.firstIndex(where: { $0.id == match.id })
}
func loserRounds() -> [Round] {
return Store.main.filter(isIncluded: { $0.loser == id }).sorted(by: \.index).reversed()
}
func loserRoundsAndChildren() -> [Round] {
let loserRounds = loserRounds()
return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() })
}
func isLoserBracket() -> Bool {
loser != nil
}
func buildLoserBracket() {
guard loserRounds().isEmpty else { return }
let currentRoundMatchCount = matches.count
guard currentRoundMatchCount > 1 else { return }
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
let loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat
let rounds = (0..<roundCount).map { //index 0 is the final
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat)
round.loser = id //parent
return round
}
try? DataStore.shared.rounds.addOrUpdate(contentOfs: rounds)
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount)
let matches = (0..<matchCount).map { //0 is final match
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0)
let round = rounds[roundIndex]
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat)
}
print(matches.map {
(RoundRule.roundName(fromMatchIndex: $0.index), RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index))
})
try? DataStore.shared.matches.addOrUpdate(contentOfs: matches)
loserRounds().forEach { round in
round.buildLoserBracket()
}
}
var parentRound: Round? {
guard let parentRound = loser else { return nil }
return Store.main.findById(parentRound)
}
override func deleteDependencies() throws {
try Store.main.deleteDependencies(items: self.matches)
if let loserRound {
try Store.main.deleteDependencies(items: [loserRound])
}
try Store.main.deleteDependencies(items: loserRoundsAndChildren())
}
enum CodingKeys: String, CodingKey {
@ -94,10 +174,18 @@ class Round: ModelObject, Storable {
extension Round: Selectable {
func selectionLabel() -> String {
roundTitle()
if let parentRound {
return "Tour #\(parentRound.loserRounds().count - index)"
} else {
return roundTitle()
}
}
func badgeValue() -> Int? {
nil
if let parentRound {
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.matches }.filter({ $0.isRunning() }).count
} else {
return matches.filter({ $0.isRunning() }).count
}
}
}

@ -183,13 +183,6 @@ class Tournament : ModelObject, Storable {
case 4...7:
return SeedInterval(first: 5, last: 8)
case 8...15:
// if 16 - 9 > availableSeeds().count {
// switch alreadySetupSeeds {
// case 8...15:
// return SeedInterval(first: 5, last: 8)
// case 8...15:
// return SeedInterval(first: 5, last: 8)
// }
return SeedInterval(first: 9, last: 16)
case 16...23:
return SeedInterval(first: 17, last: 24)
@ -230,7 +223,7 @@ class Tournament : ModelObject, Storable {
for (index, seed) in availableSeeds.enumerated() {
seed.setSeedPosition(inSpot: spots[index], upperBranch: nil, opposingSeeding: false)
}
} else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count == self.availableSeeds().count) {
} else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count <= self.availableSeeds().count) {
let spots = availableSeedOpponentSpot.shuffled()
for (index, seed) in availableSeeds.enumerated() {
@ -261,7 +254,7 @@ class Tournament : ModelObject, Storable {
}
func rounds() -> [Round] {
Store.main.filter { $0.tournament == self.id }.sorted(by: \.index).reversed()
Store.main.filter { $0.tournament == self.id && $0.loser == nil }.sorted(by: \.index).reversed()
}
func sortedTeams() -> [TeamRegistration] {
@ -619,6 +612,8 @@ class Tournament : ModelObject, Storable {
})
try? DataStore.shared.matches.addOrUpdate(contentOfs: matches)
buildLoserBracket()
}
func deleteStructure() {

@ -0,0 +1,53 @@
//
// LoserBracketView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 04/04/2024.
//
import SwiftUI
struct LoserBracketView: View {
@EnvironmentObject var dataStore: DataStore
let loserRounds: [Round]
@ViewBuilder
var body: some View {
if let first = loserRounds.first {
List {
ForEach(loserRounds) { loserRound in
_loserRoundView(loserRound)
let childLoserRounds = loserRound.loserRounds()
if childLoserRounds.isEmpty == false {
let uniqueChildRound = childLoserRounds.first
if childLoserRounds.count == 1, let uniqueChildRound {
_loserRoundView(uniqueChildRound)
} else if let uniqueChildRound {
NavigationLink {
LoserBracketView(loserRounds: childLoserRounds)
} label: {
Text(uniqueChildRound.roundTitle())
}
}
}
}
}
.navigationTitle(first.roundTitle())
}
}
private func _loserRoundView(_ loserRound: Round) -> some View {
Section {
ForEach(loserRound.matches) { match in
MatchRowView(match: match, matchViewStyle: .standardStyle)
}
} header: {
Text(loserRound.roundTitle())
}
}
}
#Preview {
LoserBracketView(loserRounds: [Round.mock()])
.environmentObject(DataStore.shared)
}

@ -0,0 +1,65 @@
//
// LoserRoundsView.swift
// PadelClub
//
// Created by Razmig Sarkissian on 04/04/2024.
//
import SwiftUI
extension Int: Selectable, Identifiable {
public var id: Int { self }
func selectionLabel() -> String {
"Tour #\(self + 1)"
}
func badgeValue() -> Int? {
nil
}
}
struct LoserRoundsView: View {
var upperBracketRound: Round
@State private var selectedRound: Round?
let loserRounds: [Round]
init(upperBracketRound: Round) {
self.upperBracketRound = upperBracketRound
self.loserRounds = upperBracketRound.loserRounds()
_selectedRound = State(wrappedValue: upperBracketRound.getActiveLoserRound())
}
var body: some View {
VStack(spacing: 0) {
GenericDestinationPickerView(selectedDestination: $selectedRound, destinations: loserRounds, nilDestinationIsValid: true)
switch selectedRound {
case .none:
List {
}
case .some(let selectedRound):
LoserRoundView(loserRounds: upperBracketRound.loserRounds(forRoundIndex: selectedRound.index))
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.visible, for: .navigationBar)
}
}
struct LoserRoundView: View {
let loserRounds: [Round]
var body: some View {
List {
ForEach(loserRounds) { loserRound in
Section {
ForEach(loserRound.matches) { match in
MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle)
}
} header: {
Text(loserRound.roundTitle(.wide))
}
}
}
.headerProminence(.increased)
}
}

@ -23,14 +23,28 @@ struct RoundSettingsView: View {
Toggle("Éditer les têtes de série", isOn: $isEditingTournamentSeed)
Section {
RowButtonView("Retirer toutes les têtes de séries") {
RowButtonView("Effacer classement", role: .destructive) {
tournament.rounds().forEach { round in
try? dataStore.rounds.delete(contentOfs: round.loserRounds())
}
}
}
Section {
RowButtonView("Match de classement") {
tournament.rounds().forEach { round in
round.buildLoserBracket()
}
}
}
Section {
RowButtonView("Retirer toutes les têtes de séries", role: .destructive) {
tournament.unsortedTeams().forEach({ $0.bracketPosition = nil })
}
}
Section {
if let lastRound = tournament.rounds().first { // first is final, last round
RowButtonView("Supprimer " + lastRound.roundTitle()) {
RowButtonView("Supprimer " + lastRound.roundTitle(), role: .destructive) {
try? dataStore.rounds.delete(instance: lastRound)
}
}
@ -47,6 +61,7 @@ struct RoundSettingsView: View {
}
try? dataStore.rounds.addOrUpdate(instance: round)
try? dataStore.matches.addOrUpdate(contentOfs: matches)
round.buildLoserBracket()
}
}

@ -12,6 +12,18 @@ struct RoundView: View {
var body: some View {
List {
let loserRounds = round.loserRounds()
if loserRounds.isEmpty == false, let first = loserRounds.first {
Section {
NavigationLink {
LoserRoundsView(upperBracketRound: round)
.navigationTitle(first.roundTitle())
} label: {
Text(first.roundTitle())
}
}
}
ForEach(round.matches) { match in
Section {
MatchRowView(match: match, matchViewStyle: .sectionedStandardStyle)

Loading…
Cancel
Save