diff --git a/PadelClub.xcodeproj/project.pbxproj b/PadelClub.xcodeproj/project.pbxproj index c4ce7b7..04b8973 100644 --- a/PadelClub.xcodeproj/project.pbxproj +++ b/PadelClub.xcodeproj/project.pbxproj @@ -3116,7 +3116,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3162,7 +3162,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3281,7 +3281,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3326,7 +3326,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3370,7 +3370,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3412,7 +3412,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.19; + MARKETING_VERSION = 1.2.20; PRODUCT_BUNDLE_IDENTIFIER = app.padelclub.beta; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/PadelClub/Data/Tournament.swift b/PadelClub/Data/Tournament.swift new file mode 100644 index 0000000..1153333 --- /dev/null +++ b/PadelClub/Data/Tournament.swift @@ -0,0 +1,2704 @@ +// +// swift +// PadelClub +// +// Created by Laurent Morvillier on 02/02/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +final class Tournament: BaseTournament { + + //local variable + var refreshInProgress: Bool = false + var lastTeamRefresh: Date? + var refreshRanking: Bool = false + + func shouldRefreshTeams(forced: Bool) -> Bool { + if forced { + return true + } + guard let lastTeamRefresh else { return true } + return lastTeamRefresh.timeIntervalSinceNow < -600 + } + + @ObservationIgnored + var navigationPath: [Screen] = [] + + var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.id) + } + + override func deleteDependencies() { + guard let store = self.tournamentStore else { return } + + let drawLogs = Array(store.drawLogs) + for drawLog in drawLogs { + drawLog.deleteDependencies() + } + store.drawLogs.deleteDependencies(drawLogs) + + let teams = Array(store.teamRegistrations) + for team in teams { + team.deleteDependencies() + } + store.teamRegistrations.deleteDependencies(teams) + + let groups = Array(store.groupStages) + for group in groups { + group.deleteDependencies() + } + store.groupStages.deleteDependencies(groups) + + let rounds = Array(store.rounds) + for round in rounds { + round.deleteDependencies() + } + store.rounds.deleteDependencies(rounds) + + store.matchSchedulers.deleteDependencies(self._matchSchedulers()) + + } + + // MARK: - Computed Dependencies + + func unsortedTeams() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.teamRegistrations) + } + + func unsortedTeamsCount() -> Int { + return self.tournamentStore?.teamRegistrations.count ?? 0 + } + + func groupStages(atStep step: Int = 0) -> [GroupStage] { + guard let tournamentStore = self.tournamentStore else { return [] } + let groupStages: [GroupStage] = tournamentStore.groupStages.filter { $0.tournament == self.id && $0.step == step } + return groupStages.sorted(by: \.index) + } + + func allGroupStages() -> [GroupStage] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder) + } + + func allRounds() -> [Round] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.rounds) + } + + // MARK: - + + enum State { + case initial + case build + case running + case canceled + case finished + } + + func eventLabel() -> String { + if let event = eventObject(), let name = event.name { + return name + } else { + return "" + } + } + + func publishedTournamentDate() -> Date { + return min(creationDate.tomorrowAtNine, startDate) + } + + func publishedTeamsDate() -> Date { + return self.startDate + } + + func canBePublished() -> Bool { + switch state() { + case .build, .finished, .running: + return unsortedTeams().count > 3 + default: + return false + } + } + + func isTournamentPublished() -> Bool { + return (Date() >= publishedTournamentDate()) || publishTournament + } + + func areTeamsPublished() -> Bool { + return Date() >= startDate || publishTeams + } + + func areSummonsPublished() -> Bool { + return Date() >= startDate || publishSummons + } + + fileprivate func _publishedDateFromMatches(_ matches: [Match]) -> Date? { + let startDates: [Date] = matches.compactMap { $0.startDate } + let sortedDates: [Date] = startDates.sorted() + + if let first: Date = sortedDates.first?.atEightAM() { + if first.isEarlierThan(startDate) { + return startDate + } else { + return first + } + } else { + return startDate + } + } + + func publishedGroupStagesDate() -> Date? { + let matches: [Match] = self.groupStages().flatMap { $0.playedMatches() } + return self._publishedDateFromMatches(matches) + } + + func areGroupStagesPublished() -> Bool { + if publishGroupStages { return true } + if let publishedGroupStagesDate = publishedGroupStagesDate() { + return Date() >= publishedGroupStagesDate + } else { + return false + } + } + + func publishedBracketsDate() -> Date? { + let matches: [Match] = self.rounds().flatMap { $0.playedMatches() } + return self._publishedDateFromMatches(matches) + } + + func areBracketsPublished() -> Bool { + if publishBrackets { return true } + if let publishedBracketsDate = publishedBracketsDate() { + return Date() >= publishedBracketsDate + } else { + return false + } + } + + func shareURL(_ pageLink: PageLink = .matches) -> URL? { + if pageLink == .clubBroadcast { + let club = club() +// print("club", club) +// print("club broadcast code", club?.broadcastCode) + if let club, let broadcastCode = club.broadcastCode { + return URLs.main.url.appending(path: "c/\(broadcastCode)") + } else { + return nil + } + } + return URLs.main.url.appending(path: "tournament/\(id)").appending(path: pageLink.path) + } + + func courtUsed(runningMatches: [Match]) -> [Int] { +#if _DEBUGING_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func courtUsed()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + return Set(runningMatches.compactMap { $0.courtIndex }).sorted() + } + + func hasStarted() -> Bool { + return startDate <= Date() + } + + func eventObject() -> Event? { + guard let event else { return nil } + return Store.main.findById(event) + } + + func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText) -> String { + let _selectedSortedTeams = selectedSortedTeams() + let selectedSortedTeams = _selectedSortedTeams + waitingListSortedTeams(selectedSortedTeams: _selectedSortedTeams) + switch exportFormat { + case .rawText: + return (selectedSortedTeams.compactMap { $0.pasteData(exportFormat) } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true).compactMap { $0.pasteData(exportFormat) }).joined(separator: exportFormat.newLineSeparator(2)) + case .csv: + let headers = ["N°", "Nom Prénom", "rang", "Nom Prénom", "rang", "poids"].joined(separator: exportFormat.separator()) + var teamPaste = [headers] + for (index, team) in selectedSortedTeams.enumerated() { + teamPaste.append(team.pasteData(exportFormat, index + 1)) + } + return teamPaste.joined(separator: exportFormat.newLineSeparator()) + } + } + + func club() -> Club? { + return eventObject()?.clubObject() + } + + func locationLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if let club = club() { + switch displayStyle { + case .wide, .title: + return club.name + case .short: + return club.acronym + } + } else { + return "" + } + } + + func hasEnded() -> Bool { + return endDate != nil + } + + func state() -> State { + if self.isCanceled == true { + return .canceled + } + + if self.hasEnded() { return .finished } + + let isBuild = (groupStageCount > 0 && groupStages().isEmpty == false) + || rounds().isEmpty == false + + if isBuild && startDate <= Date() { return .running } + + if isBuild { + return .build + } + return .initial + } + + func seededTeams() -> [TeamRegistration] { + return selectedSortedTeams().filter({ $0.bracketPosition != nil && $0.groupStagePosition == nil }) + } + + func groupStageTeams() -> [TeamRegistration] { + return selectedSortedTeams().filter({ $0.groupStagePosition != nil }) + } + + func groupStageSpots() -> Int { + return groupStages().map { $0.size }.reduce(0,+) + } + + func seeds() -> [TeamRegistration] { + let selectedSortedTeams = selectedSortedTeams() + let seeds = max(selectedSortedTeams.count - groupStageSpots() , 0) + return Array(selectedSortedTeams.prefix(seeds)) + } + + func availableSeeds() -> [TeamRegistration] { +#if _DEBUG_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func availableSeeds()", duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + + + return seeds().filter { $0.isSeedable() } + } + + func lastSeedRound() -> Int { + if let last = seeds().filter({ $0.bracketPosition != nil }).last { + return RoundRule.roundIndex(fromMatchIndex: last.bracketPosition! / 2) + } else { + return 0 + } + } + + func getRound(atRoundIndex roundIndex: Int) -> Round? { + return self.tournamentStore?.rounds.first(where: { $0.index == roundIndex }) +// return Store.main.filter(isIncluded: { $0.tournament == id && $0.index == roundIndex }).first + } + + func availableSeedSpot(inRoundIndex roundIndex: Int) -> [Match] { + return getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.isEmpty() } ?? [] + } + + func availableSeedOpponentSpot(inRoundIndex roundIndex: Int) -> [Match] { + return getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.hasSpaceLeft() } ?? [] + } + func availableSeedGroups(includeAll: Bool = false) -> [SeedInterval] { + let seeds = seeds() + var availableSeedGroup = Set() + for (index, seed) in seeds.enumerated() { + if seed.isSeedable(), let seedGroup = seedGroup(for: index) { + if includeAll { + if let chunks = seedGroup.chunks() { + chunksBy(in: chunks, availableSeedGroup: &availableSeedGroup) + } + } else { + availableSeedGroup.insert(seedGroup) + } + } + } + return availableSeedGroup.sorted(by: <) + } + + func chunksBy(in chunks: [SeedInterval], availableSeedGroup: inout Set) { + chunks.forEach { chunk in + availableSeedGroup.insert(chunk) + if let moreChunk = chunk.chunks() { + self.chunksBy(in: moreChunk, availableSeedGroup: &availableSeedGroup) + } + } + } + + func seedGroup(for alreadySetupSeeds: Int) -> SeedInterval? { + switch alreadySetupSeeds { + case 0...1: + return SeedInterval(first: 1, last: 2) + case 2...3: + return SeedInterval(first: 3, last: 4) + case 4...7: + return SeedInterval(first: 5, last: 8) + case 8...15: + return SeedInterval(first: 9, last: 16) + case 16...23: + return SeedInterval(first: 17, last: 24) + case 24...31: + return SeedInterval(first: 25, last: 32) + default: + return nil + } + } + + func availableSeedGroup() -> SeedInterval? { + let seeds = seeds() + if let firstIndex = seeds.firstIndex(where: { $0.isSeedable() }) { + guard let seedGroup = seedGroup(for: firstIndex) else { return nil } + return seedGroup + } + return nil + } + + func randomSeed(fromSeedGroup seedGroup: SeedInterval) -> TeamRegistration? { + let availableSeeds = seeds(inSeedGroup: seedGroup) + return availableSeeds.randomElement() + } + + func seeds(inSeedGroup seedGroup: SeedInterval) -> [TeamRegistration] { + let availableSeedInSeedGroup = (seedGroup.last - seedGroup.first) + 1 + let availableSeeds = seeds().dropFirst(seedGroup.first - 1).prefix(availableSeedInSeedGroup).filter({ $0.isSeedable() }) + return availableSeeds + } + + func seedGroupAvailable(atRoundIndex roundIndex: Int) -> SeedInterval? { + if let availableSeedGroup = availableSeedGroup() { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: availableSeedGroup) + } else { + return nil + } + } + + func seedGroupAvailable(atRoundIndex roundIndex: Int, availableSeedGroup: SeedInterval) -> SeedInterval? { + let fullLeftSeeds = availableSeeds() + if fullLeftSeeds.isEmpty == false && roundIndex >= lastSeedRound() { + + if availableSeedGroup == SeedInterval(first: 1, last: 2) { return availableSeedGroup } + let availableSeeds = seeds(inSeedGroup: availableSeedGroup) + let availableSeedSpot = availableSeedSpot(inRoundIndex: roundIndex) + let availableSeedOpponentSpot = availableSeedOpponentSpot(inRoundIndex: roundIndex) + let targetSpots = availableSeedSpot.isEmpty ? availableSeedOpponentSpot.count : availableSeedSpot.count + if availableSeedGroup == SeedInterval(first: 3, last: 4), availableSeedSpot.count == 6 { + return availableSeedGroup + } + if availableSeeds.count == availableSeedSpot.count && availableSeedGroup.count == availableSeeds.count { + return availableSeedGroup + } else if availableSeeds.count == availableSeedOpponentSpot.count && availableSeedGroup.count == availableSeedOpponentSpot.count { + return availableSeedGroup + } else if let chunks = availableSeedGroup.chunks() { + let seededTeamsCount = self.seededTeams().count + if let chunk = chunks.first(where: { seedInterval in + return seedInterval.first == seededTeamsCount + }) { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: chunk) + } else if fullLeftSeeds.count > 1, targetSpots > 1, fullLeftSeeds.count >= targetSpots { + let currentSeeds = seeds() + if let firstIndex = currentSeeds.firstIndex(where: { $0.isSeedable() }) { + + if firstIndex < seededTeamsCount { + return nil + } else { + let sg = SeedInterval(first: seededTeamsCount + 1, last: seededTeamsCount + targetSpots) + let futureAvailableSeeds = self.seeds(inSeedGroup: sg) + if futureAvailableSeeds.count == targetSpots { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: sg) + } else { + return nil + } + } + } + } + } + } + + return nil + } + + func setSeeds(inRoundIndex roundIndex: Int, inSeedGroup seedGroup: SeedInterval) { + if seedGroup == SeedInterval(first: 1, last: 2) { + let seeds = seeds() + if let matches = getRound(atRoundIndex: roundIndex)?.playedMatches() { + if let lastMatch = matches.last { + seeds.prefix(1).first?.setSeedPosition(inSpot: lastMatch, slot: .two, opposingSeeding: false) + } + if let firstMatch = matches.first { + seeds.prefix(2).dropFirst().first?.setSeedPosition(inSpot: firstMatch, slot: .one, opposingSeeding: false) + } + } + } else { + + let availableSeedSpot = availableSeedSpot(inRoundIndex: roundIndex) + let availableSeedOpponentSpot = availableSeedOpponentSpot(inRoundIndex: roundIndex) + let availableSeeds = seeds(inSeedGroup: seedGroup) + + if seedGroup == SeedInterval(first: 3, last: 4), availableSeedSpot.count == 6 { + var spots = [Match]() + spots.append(availableSeedSpot[1]) + spots.append(availableSeedSpot[4]) + spots = spots.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) + } +// } else if seedGroup == SeedInterval(first: 5, last: 6), availableSeedSpot.count == 4 { +// var spots = [Match]() +// spots.append(availableSeedSpot[1]) +// spots.append(availableSeedSpot[2]) +// spots = spots.shuffled() +// for (index, seed) in availableSeeds.enumerated() { +// seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) +// } + } else { + if availableSeeds.count <= availableSeedSpot.count { + let spots = availableSeedSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) + } + } else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count <= self.availableSeeds().count) { + + let spots = availableSeedOpponentSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: true) + } + } else if let chunks = seedGroup.chunks() { + if let chunk = chunks.first(where: { seedInterval in + seedInterval.first >= self.seededTeams().count + }) { + setSeeds(inRoundIndex: roundIndex, inSeedGroup: chunk) + } + } + } + } + } + + + func inscriptionClosed() -> Bool { + closedRegistrationDate != nil + } + + 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 + } + + func matchesWithSpace() -> [Match] { + getActiveRound()?.playedMatches().filter({ $0.hasSpaceLeft() }) ?? [] + } + + func getActiveRound(withSeeds: Bool = false) -> Round? { + let rounds: [Round] = self.rounds() + + for round in rounds { + let playedMatches = round.playedMatches() + + // Optimization: If no matches have started in this round, return nil immediately + if !playedMatches.contains(where: { $0.hasStarted() }) { + return round + } + + if playedMatches.contains(where: { $0.hasStarted() && !$0.hasEnded() }) { + if withSeeds { + if !round.seeds().isEmpty { + return round + } else { + return nil + } + } else { + return round + } + } + } + + return nil + } + + func getActiveRoundAndStatus() -> (Round, String)? { + let rounds: [Round] = self.rounds() + + for round in rounds { + let playedMatches = round.playedMatches() + + // Optimization: If no matches have started in this round, return nil immediately + if !playedMatches.contains(where: { $0.hasStarted() }) { + return (round, round.roundStatus(playedMatches: playedMatches)) + } + + if playedMatches.contains(where: { $0.hasStarted() && !$0.hasEnded() }) { + return (round, round.roundStatus(playedMatches: playedMatches)) + } + } + return nil + } + + func getPlayedMatchDateIntervals(in event: Event) -> [DateInterval] { + let allMatches: [Match] = self.allMatches().filter { $0.courtIndex != nil && $0.startDate != nil } + return allMatches.map { match in + DateInterval(event: event.id, courtIndex: match.courtIndex!, startDate: match.startDate!, endDate: match.estimatedEndDate(additionalEstimationDuration)!) + } + } + + func allRoundMatches() -> [Match] { + return allRounds().flatMap { $0._matches() } + } + + func allMatches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter { $0.disabled == false } + } + + func _allMatchesIncludingDisabled() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.matches) + } + + func rounds() -> [Round] { + guard let tournamentStore = self.tournamentStore else { return [] } + let rounds: [Round] = tournamentStore.rounds.filter { $0.isUpperBracket() } + return rounds.sorted { $0.index > $1.index } + } + + func sortedTeams(selectedSortedTeams: [TeamRegistration]) -> [TeamRegistration] { + let teams = selectedSortedTeams + return teams + waitingListTeams(in: teams, includingWalkOuts: true) + } + + func waitingListSortedTeams(selectedSortedTeams: [TeamRegistration]) -> [TeamRegistration] { + let teams = selectedSortedTeams + return waitingListTeams(in: teams, includingWalkOuts: false) + } + + + func selectedSortedTeams() -> [TeamRegistration] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func selectedSortedTeams", id, tournamentTitle(), duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + var _sortedTeams : [TeamRegistration] = [] + var _teams = unsortedTeams().filter({ $0.isOutOfTournament() == false }) + + if let closedRegistrationDate { + _teams = _teams.filter({ team in + if let registrationDate = team.registrationDate { + return registrationDate <= closedRegistrationDate + } else { + return true + } + }) + } + + let defaultSorting : [MySortDescriptor] = _defaultSorting() + + let _completeTeams = _teams.sorted(using: defaultSorting, order: .ascending).filter { $0.isWildCard() == false }.prefix(teamCount).sorted(using: [.keyPath(\.initialWeight), .keyPath(\.id)], order: .ascending) + + let wcGroupStage = _teams.filter { $0.wildCardGroupStage }.sorted(using: _currentSelectionSorting, order: .ascending) + + let wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending) + + let groupStageSpots: Int = self.groupStageSpots() + var bracketSeeds: Int = teamCount - groupStageSpots - wcBracket.count + var groupStageTeamCount: Int = groupStageSpots - wcGroupStage.count + if groupStageTeamCount < 0 { groupStageTeamCount = 0 } + if bracketSeeds < 0 { bracketSeeds = 0 } + + if prioritizeClubMembers { + + var bracketTeams: [TeamRegistration] = [] + bracketTeams.append(contentsOf: _completeTeams.filter { $0.hasMemberOfClub(clubName) }) + + let others: [TeamRegistration] = _completeTeams.filter { $0.hasMemberOfClub(clubName) == false } + let sortedOthers: [TeamRegistration] = others.sorted(using: defaultSorting, order: .ascending) + bracketTeams.append(contentsOf: sortedOthers) + + bracketTeams = bracketTeams + .prefix(bracketSeeds) + .sorted(using: _currentSelectionSorting, order: .ascending) + bracketTeams.append(contentsOf: wcBracket) + +// let bracketTeams: [TeamRegistration] = (_completeTeams.filter { $0.hasMemberOfClub(clubName) } + _completeTeams.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + + let groupStageTeamsNoFiltering = Set(_completeTeams).subtracting(bracketTeams) + let groupStageTeams = (groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) } + groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } else { + let bracketTeams = _completeTeams.prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + let groupStageTeams = Set(_completeTeams).subtracting(bracketTeams).sorted(using: defaultSorting, order: .ascending).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } + return _sortedTeams + } + + func waitingListTeams(in teams: [TeamRegistration], includingWalkOuts: Bool) -> [TeamRegistration] { + let waitingList = Set(unsortedTeams()).subtracting(teams) + let waitings = waitingList.filter { $0.isOutOfTournament() == false }.sorted(using: _defaultSorting(), order: .ascending) + let walkOuts = waitingList.filter { $0.walkOut == true }.sorted(using: _defaultSorting(), order: .ascending) + if includingWalkOuts { + return waitings + walkOuts + } else { + return waitings + } + } + + func bracketCut(teamCount: Int, groupStageCut: Int) -> Int { + return self.teamCount - groupStageCut + } + + func groupStageCut() -> Int { + return groupStageSpots() + } + + func cutLabel(index: Int, teamCount: Int?) -> String { + let _teamCount = teamCount ?? selectedSortedTeams().count + let groupStageCut = groupStageCut() + let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut) + + if index < bracketCut { + return "Tableau" + } else if index - bracketCut < groupStageCut && _teamCount > 0 { + return "Poule" + } else { + return "Attente" + } + } + + func cutLabelColor(index: Int?, teamCount: Int?) -> Color { + guard let index else { return Color.grayNotUniversal } + let _teamCount = teamCount ?? selectedSortedTeams().count + let groupStageCut = groupStageCut() + let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut) + if index < bracketCut { + return Color.mint + } else if index - bracketCut < groupStageCut && _teamCount > 0 { + return Color.indigo + } else { + return Color.grayNotUniversal + } + } + + func unsortedTeamsWithoutWO() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamRegistrations.filter { $0.isOutOfTournament() == false } + } + + func walkoutTeams() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamRegistrations.filter { $0.walkOut == true } +// return Store.main.filter { $0.tournament == self.id && $0.walkOut == true } + } + + func duplicates(in players: [PlayerRegistration]) -> [PlayerRegistration] { + var duplicates = [PlayerRegistration]() + Set(players.compactMap({ $0.licenceId })).forEach { licenceId in + let found = players.filter({ $0.licenceId?.strippedLicense == licenceId.strippedLicense }) + if found.count > 1 { + duplicates.append(found.first!) + } + } + return duplicates + } + + func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] { + players.filter({ $0.hasHomonym() }) + } + + func unsortedPlayers() -> [PlayerRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.playerRegistrations) + } + + func selectedPlayers() -> [PlayerRegistration] { + return self.selectedSortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.computedRank) + } + + func paidSelectedPlayers(type: PlayerPaymentType) -> Double? { + if let entryFee { + let flat = self.selectedSortedTeams().flatMap { $0.unsortedPlayers() } + let count = flat.filter { $0.paymentType == type }.count + return Double(count) * entryFee + } else { + return nil + } + } + + func players() -> [PlayerRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.playerRegistrations.sorted(by: \.computedRank) + } + + func unrankValue(for malePlayer: Bool) -> Int? { + switch tournamentCategory { + case .unlisted: + return nil + case .men: + return maleUnrankedValue + case .women: + return femaleUnrankedValue + case .mix: + return malePlayer ? maleUnrankedValue : femaleUnrankedValue + } + } + + //todo + var clubName: String? { + return self.eventObject()?.clubObject()?.name + } + + //todo + func significantPlayerCount() -> Int { + return minimumPlayerPerTeam + } + + func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { + if startDate.isInCurrentYear() == false { + return [] + } + return players.filter { player in + return isPlayerRankInadequate(player: player) + } + } + + func isPlayerRankInadequate(player: PlayerHolder) -> Bool { + guard let rank = player.getRank() else { return false } + let _rank = player.male ? rank : rank + PlayerRegistration.addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0) + if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) { + return true + } else { + return false + } + } + + func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] { + if startDate.isInCurrentYear() == false { + return [] + } + return players.filter { player in + return isPlayerAgeInadequate(player: player) + } + } + + func isPlayerAgeInadequate(player: PlayerHolder) -> Bool { + guard let computedAge = player.computedAge else { return false } + if federalTournamentAge.isAgeValid(age: computedAge) == false { + return true + } else { + return false + } + } + + func licenseYearValidity() -> Int { + if startDate.get(.month) > 8 { + return startDate.get(.year) + 1 + } else { + return startDate.get(.year) + } + } + + func playersWithoutValidLicense(in players: [PlayerRegistration], isImported: Bool) -> [PlayerRegistration] { + let licenseYearValidity = self.licenseYearValidity() + return players.filter({ player in + if player.isImported() { + // Player is marked as imported: check if the license is valid + return !player.isValidLicenseNumber(year: licenseYearValidity) + } else { + // Player is not imported: validate license and handle `isImported` flag for non-imported players + let noLicenseId = player.licenceId == nil || player.licenceId?.isEmpty == true + let invalidFormattedLicense = player.formattedLicense().isLicenseNumber == false + + // If global `isImported` is true, check license number as well + let invalidLicenseForImportedFlag = isImported && !player.isValidLicenseNumber(year: licenseYearValidity) + + return noLicenseId || invalidFormattedLicense || invalidLicenseForImportedFlag + } + }) + } + + func getStartDate(ofSeedIndex seedIndex: Int?) -> Date? { + guard let seedIndex else { return nil } + return selectedSortedTeams()[safe: seedIndex]?.callDate + } + + func importTeams(_ teams: [FileImportManager.TeamHolder]) { + var teamsToImport = [TeamRegistration]() + let players = players().filter { $0.licenceId != nil } + teams.forEach { team in + if let previousTeam = team.previousTeam { + previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory) + teamsToImport.append(previousTeam) + } else { + var registrationDate = team.registrationDate + if let previousPlayer = players.first(where: { player in + let ids = team.players.compactMap({ $0.licenceId }) + return ids.contains(player.licenceId!) + }), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate { + registrationDate = previousTeamRegistrationDate + } + let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name) + if isAnimation() { + if newTeam.weight == 0 { + newTeam.weight = team.index(in: teams) ?? 0 + } + } + teamsToImport.append(newTeam) + } + } + + if let tournamentStore = self.tournamentStore { + tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport) + tournamentStore.playerRegistrations.addOrUpdate(contentOfs: teams.flatMap { $0.players }) + } + + if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty { + setGroupStage(randomize: groupStageSortMode == .random) + } + } + + func maximumCourtsPerGroupSage() -> Int { + if teamsPerGroupStage > 1 { + return min(teamsPerGroupStage / 2, courtCount) + } else { + return max(1, courtCount) + } + } + + func registrationIssues(selectedTeams: [TeamRegistration]) async -> Int { + let players : [PlayerRegistration] = unsortedPlayers() + let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) } + let duplicates : [PlayerRegistration] = duplicates(in: players) + let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil }) + let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players) + let homonyms = homonyms(in: players) + let ageInadequatePlayers = ageInadequatePlayers(in: players) + let isImported = players.anySatisfy({ $0.isImported() }) + let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players, isImported: isImported) + let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 }) + let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true) + let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil }) + let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil }) + + return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count + ageInadequatePlayers.count + homonyms.count + } + + func isStartDateIsDifferentThanCallDate(_ team: TeamRegistration, expectedSummonDate: Date? = nil) -> Bool { + guard let summonDate = team.callDate else { return true } + let expectedSummonDate : Date? = team.expectedSummonDate() ?? expectedSummonDate + guard let expectedSummonDate else { return true } + return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame + } + + 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!) }) + } + + static let defaultSorting : [MySortDescriptor] = [.keyPath(\Match.computedStartDateForSorting), .keyPath(\Match.computedOrder)] + + static func availableToStart(_ allMatches: [Match], in runningMatches: [Match], checkCanPlay: Bool = true) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament availableToStart", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() == false && $0.canBeStarted(inMatches: runningMatches, checkCanPlay: checkCanPlay) }).sorted(using: defaultSorting, order: .ascending) + } + + static func runningMatches(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament runningMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() && $0.isReady() }).sorted(using: defaultSorting, order: .ascending) + } + + static func readyMatches(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false }).sorted(using: defaultSorting, order: .ascending) + } + + static func matchesLeft(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() == false && $0.hasEnded() == false }).sorted(using: defaultSorting, order: .ascending) + } + + + static func finishedMatches(_ allMatches: [Match], limit: Int?) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament finishedMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + if let limit { + return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed().prefix(limit)) + } else { + return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed()) + } + } + + func teamsRanked() -> [TeamRegistration] { + let selected = selectedSortedTeams().filter({ $0.finalRanking != nil }) + return selected.sorted(by: \.finalRanking!, order: .ascending) + } + + private func _removeStrings(from dictionary: inout [Int: [String]], stringsToRemove: [String]) { + for key in dictionary.keys { + if var stringArray = dictionary[key] { + // Remove all instances of each string in stringsToRemove + stringArray.removeAll { stringsToRemove.contains($0) } + dictionary[key] = stringArray + } + } + } + + + func finalRanking() async -> [Int: [String]] { + 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 { + let groupStageTeams = groupStage.teams(true) + for teamIndex in 0.. 0 { + baseRank += qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified - 1 + } + let alreadyPlaceTeams = Array(teams.values.flatMap({ $0 })) + groupStages.forEach { groupStage in + let groupStageTeams = groupStage.teams(true) + for (index, team) in groupStageTeams.enumerated() { + if groupStage.hasEnded() { + if team.qualified == false && alreadyPlaceTeams.contains(team.id) == false { + let groupStageWidth = max(((index == qualifiedPerGroupStage) ? groupStageCount - groupStageAdditionalQualified : groupStageCount) * (index - qualifiedPerGroupStage), 0) + + let _index = baseRank + groupStageWidth + 1 - (index > qualifiedPerGroupStage ? groupStageAdditionalQualified : 0) + print("finalRanking", team.teamLabel() , _index, baseRank, groupStageWidth) + if let existingTeams = teams[_index] { + teams[_index] = existingTeams + [team.id] + } else { + teams[_index] = [team.id] + } + } + } + } + } + } + + return teams + } + + func setRankings(assimilationLevel: TournamentLevel? = nil, finalRanks: [Int: [String]]) async -> [Int: [TeamRegistration]] { + guard let tournamentStore = self.tournamentStore else { return [:] } + let tournamentLevel = assimilationLevel ?? tournamentLevel + var rankings: [Int: [TeamRegistration]] = [:] + + finalRanks.keys.sorted().forEach { rank in + if let rankedTeamIds = finalRanks[rank] { + let teams: [TeamRegistration] = rankedTeamIds.compactMap { tournamentStore.teamRegistrations.findById($0) } + rankings[rank] = teams + } + } + + rankings.keys.sorted().forEach { rank in + if let rankedTeams = rankings[rank] { + rankedTeams.forEach { team in + team.finalRanking = rank + team.pointsEarned = isAnimation() ? nil : tournamentLevel.points(for: rank - 1, count: teamCount) + } + } + } + + do { + try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams()) + } catch { + Logger.error(error) + } + + if self.publishRankings == false { + self.publishRankings = true + do { + try DataStore.shared.tournaments.addOrUpdate(instance: self) + } catch { + Logger.error(error) + } + } + + return rankings + } + + func refreshPointsEarned(assimilationLevel: TournamentLevel? = nil) { + guard let tournamentStore = self.tournamentStore else { return } + let tournamentLevel = assimilationLevel ?? tournamentLevel + let unsortedTeams = unsortedTeams() + unsortedTeams.forEach { team in + if let finalRanking = team.finalRanking { + team.pointsEarned = isAnimation() ? nil : tournamentLevel.points(for: finalRanking - 1, count: teamCount) + } + } + tournamentStore.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams) + } + + + func lockRegistration() { + closedRegistrationDate = Date() + let count = selectedSortedTeams().count + if teamCount != count { + teamCount = count + } + let teams = unsortedTeams() + teams.forEach { team in + team.lockedWeight = team.weight + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + func unlockRegistration() { + closedRegistrationDate = nil + let teams = unsortedTeams() + teams.forEach { team in + team.lockedWeight = nil + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + func updateWeights() { + let teams = self.unsortedTeams() + teams.forEach { team in + let players = team.unsortedPlayers() + players.forEach { $0.setComputedRank(in: self) } + team.setWeight(from: players, inTournamentCategory: tournamentCategory) + self.tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players) + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + func updateRank(to newDate: Date?, forceRefreshLockWeight: Bool, providedSources: [CSVParser]?) async throws { + refreshRanking = true + #if DEBUG_TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + + guard let newDate else { return } + rankSourceDate = newDate + + // Fetch current month data only once + var monthData = currentMonthData() + + if monthData == nil { + async let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate) + async let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate) + + let formatted = URL.importDateFormatter.string(from: newDate) + let newMonthData = MonthData(monthKey: formatted) + + newMonthData.maleUnrankedValue = await lastRankMan + newMonthData.femaleUnrankedValue = await lastRankWoman + + do { + try DataStore.shared.monthData.addOrUpdate(instance: newMonthData) + } catch { + Logger.error(error) + } + + monthData = newMonthData + } + + let lastRankMan = monthData?.maleUnrankedValue + let lastRankWoman = monthData?.femaleUnrankedValue + + var chunkedParsers: [CSVParser] = [] + if let providedSources { + chunkedParsers = providedSources + } else { + // Fetch only the required files + let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate } + guard !dataURLs.isEmpty else { return } // Early return if no files found + + let sources = dataURLs.map { CSVParser(url: $0) } + chunkedParsers = try await chunkAllSources(sources: sources, size: 10000) + } + + let players = unsortedPlayers() + try await players.concurrentForEach { player in + let lastRank = (player.sex == .female) ? lastRankWoman : lastRankMan + try await player.updateRank(from: chunkedParsers, lastRank: lastRank) + player.setComputedRank(in: self) + } + + if providedSources == nil { + try chunkedParsers.forEach { chunk in + try FileManager.default.removeItem(at: chunk.url) + } + } + + try tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players) + + let unsortedTeams = unsortedTeams() + unsortedTeams.forEach { team in + team.setWeight(from: team.players(), inTournamentCategory: tournamentCategory) + if forceRefreshLockWeight { + team.lockedWeight = team.weight + } + } + try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams) + refreshRanking = false + } + + + func missingUnrankedValue() -> Bool { + return maleUnrankedValue == nil || femaleUnrankedValue == nil + } + + func findTeam(_ players: [PlayerRegistration]) -> TeamRegistration? { + return unsortedTeams().first(where: { $0.includes(players: players) }) + } + + func tournamentTitle(_ displayStyle: DisplayStyle = .wide, hideSenior: Bool = false) -> String { + if tournamentLevel == .unlisted, displayStyle == .title { + if let name { + return name + } else { + return tournamentLevel.localizedLevelLabel(.title) + } + } + let displayStyleCategory = hideSenior ? .short : displayStyle + var levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle), tournamentCategory.localizedCategoryLabel(displayStyle, ageCategory: federalAgeCategory)] + if displayStyle == .short { + levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle) + tournamentCategory.localizedCategoryLabel(displayStyle, ageCategory: federalAgeCategory)] + } + let array = levelCategory + [federalTournamentAge.localizedFederalAgeLabel(displayStyleCategory)] + let title: String = array.filter({ $0.isEmpty == false }).joined(separator: " ") + if displayStyle == .wide, let name { + return [title, name].joined(separator: " - ") + } else { + return title + } + } + + func localizedTournamentType() -> String { + switch tournamentLevel { + case .unlisted, .championship: + return tournamentLevel.localizedLevelLabel(.short) + default: + return tournamentLevel.localizedLevelLabel(.short) + tournamentCategory.localizedCategoryLabel(.short, ageCategory: federalAgeCategory) + } + } + + func hideWeight() -> Bool { + return hideTeamsWeight + } + + func isAnimation() -> Bool { + federalLevelCategory.isAnimation() + } + + func subtitle(_ displayStyle: DisplayStyle = .wide) -> String { + return name ?? "" + } + + func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String { + switch displayStyle { + case .title: + startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).year()) + case .wide: + startDate.formatted(date: Date.FormatStyle.DateStyle.complete, time: Date.FormatStyle.TimeStyle.omitted) + case .short: + startDate.formatted(date: .numeric, time: .omitted) + } + } + + func qualifiedFromGroupStage() -> Int { + return groupStageCount * qualifiedPerGroupStage + } + + + func availableQualifiedTeams() -> [TeamRegistration] { +#if _DEBUG_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func availableQualifiedTeams()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + + return unsortedTeams().filter({ $0.qualified && $0.bracketPosition == nil }) + } + + func qualifiedTeams() -> [TeamRegistration] { + return unsortedTeams().filter({ $0.qualified }) + } + + func moreQualifiedToDraw() -> Int { + return max((qualifiedFromGroupStage() + groupStageAdditionalQualified) - qualifiedTeams().count, 0) + } + + func missingQualifiedFromGroupStages() -> [TeamRegistration] { + if groupStageAdditionalQualified > 0 && groupStagesAreOver() { + return groupStages().filter { $0.hasEnded() }.compactMap { groupStage in + groupStage.teams(true)[safe: qualifiedPerGroupStage] + } + .filter({ $0.qualified == false }) + } else { + return [] + } + } + + func groupStagesAreOver(atStep: Int = 0) -> Bool { + let groupStages = groupStages(atStep: atStep) + guard groupStages.isEmpty == false else { + return true + } + return groupStages.allSatisfy({ $0.hasEnded() }) + //return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified + } + + func groupStageLoserBracketAreOver() -> Bool { + guard let groupStageLoserBracket = groupStageLoserBracket() else { + return true + } + return groupStageLoserBracket.hasEnded() + } + + fileprivate func _paymentMethodMessage() -> String? { + return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods + } + + var entryFeeMessage: String { + if let entryFee { + let message: String = "Inscription : \(entryFee.formatted(.currency(code: Locale.defaultCurrency()))) par joueur." + return [message, self._paymentMethodMessage()].compactMap { $0 }.joined(separator: "\n") + } else { + return "Inscription : gratuite." + } + } + + func umpireMail() -> [String]? { + return [umpireCustomMail ?? DataStore.shared.user.email] + } + + func earnings() -> Double { + if let entryFee { + return Double(selectedPlayers().filter { $0.hasPaid() }.count) * entryFee + } else { + return 0.0 + } + } + + func remainingAmount() -> Double { + if let entryFee { + return Double(selectedPlayers().filter { $0.hasPaid() == false }.count) * entryFee + } else { + return 0.0 + } + } + + func paidCompletion() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasPaid() }.count) / Double(selectedPlayers.count) + } + + func presenceStatus() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasArrived }.count) / Double(selectedPlayers.count) + } + + typealias TournamentStatus = (label:String, completion: String) + func cashierStatus() async -> TournamentStatus { + let selectedPlayers = selectedPlayers() + var filteredPlayers = [PlayerRegistration]() + var wording = "" + if isFree() { + wording = "présent" + filteredPlayers = selectedPlayers.filter({ $0.hasArrived }) + } else { + wording = "encaissé" + filteredPlayers = selectedPlayers.filter({ $0.hasPaid() }) + } +// let label = paid.count.formatted() + " / " + selectedPlayers.count.formatted() + " joueurs encaissés" + let label = "\(filteredPlayers.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs \(wording)\(filteredPlayers.count.pluralSuffix)" + let completion = (Double(filteredPlayers.count) / Double(selectedPlayers.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + func scheduleStatus() async -> TournamentStatus { + let allMatches = allMatches() + let ready = allMatches.filter({ $0.startDate != nil }) +// let label = ready.count.formatted() + " / " + allMatches.count.formatted() + " matchs programmés" + let label = "\(ready.count.formatted()) / \(allMatches.count.formatted()) matchs programmés" + let completion = (Double(ready.count) / Double(allMatches.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + func callStatus() async -> TournamentStatus { + let selectedSortedTeams = selectedSortedTeams() + let called = selectedSortedTeams.filter { isStartDateIsDifferentThanCallDate($0) == false } + let justCalled = selectedSortedTeams.filter { $0.called() } + + let label = "\(justCalled.count.formatted()) / \(selectedSortedTeams.count.formatted()) (\(called.count.formatted()) au bon horaire)" + let completion = (Double(called.count) / Double(selectedSortedTeams.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + func confirmedSummonStatus() async -> TournamentStatus { + let selectedSortedTeams = selectedSortedTeams() + let called = selectedSortedTeams.filter { $0.confirmationDate != nil } + let label = "\(called.count.formatted()) / \(selectedSortedTeams.count.formatted()) confirmées" + let completion = (Double(called.count) / Double(selectedSortedTeams.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + func bracketStatus() async -> (status: String, description: String?, cut: TeamRegistration.TeamRange?) { + let availableSeeds = availableSeeds() + var description: String? = nil + if availableSeeds.isEmpty == false { + description = "placer \(availableSeeds.count) équipe\(availableSeeds.count.pluralSuffix)" + } + if description == nil { + let availableQualifiedTeams = availableQualifiedTeams() + if availableQualifiedTeams.isEmpty == false { + description = "placer \(availableQualifiedTeams.count) qualifié" + availableQualifiedTeams.count.pluralSuffix + } + } + + var cut: TeamRegistration.TeamRange? = nil + if description == nil && isAnimation() == false { + cut = TeamRegistration.TeamRange(availableSeeds.first, availableSeeds.last) + } + + if let roundAndStatus = getActiveRoundAndStatus() { + return ([roundAndStatus.0.roundTitle(.short), roundAndStatus.1].joined(separator: " ").lowercased(), description, cut) + } else { + return ("", description, nil) + } + } + + func groupStageStatus() async -> (status: String, cut: TeamRegistration.TeamRange?) { + let groupStageTeams = groupStageTeams() + let groupStageTeamsCount = groupStageTeams.count + if groupStageTeamsCount == 0 || groupStageTeamsCount != groupStageSpots() { + return ("à compléter", nil) + } + + let cut : TeamRegistration.TeamRange? = isAnimation() ? nil : TeamRegistration.TeamRange(groupStageTeams.first, groupStageTeams.last) + + if groupStagesAreOver() { return ("terminées", cut) } + let groupStages = groupStages() + let runningGroupStages = groupStages.filter({ $0.isRunning() }) + if runningGroupStages.isEmpty { + + let ongoingGroupStages = runningGroupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }) + if ongoingGroupStages.isEmpty == false { + return ("Poule" + ongoingGroupStages.count.pluralSuffix + " " + ongoingGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) + } + return (groupStages.count.formatted() + " poule" + groupStages.count.pluralSuffix, cut) + } else { + return ("Poule" + runningGroupStages.count.pluralSuffix + " " + runningGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) + } + } + + func settingsDescriptionLocalizedLabel() -> String { + [courtCount.formatted() + " terrain\(courtCount.pluralSuffix)", entryFeeMessage].joined(separator: ", ") + } + + func structureDescriptionLocalizedLabel() -> String { + let groupStageLabel: String? = groupStageCount > 0 ? groupStageCount.formatted() + " poule\(groupStageCount.pluralSuffix)" : nil + return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") + } + + func deleteAndBuildEverything(preset: PadelTournamentStructurePreset = .manual) { + resetBracketPosition() + deleteStructure() + deleteGroupStages() + + switch preset { + case .doubleGroupStage: + buildGroupStages() + addNewGroupStageStep() + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + default: + buildGroupStages() + buildBracket() + } + } + + func addWildCardIfNeeded(_ count: Int, _ type: MatchType) { + let currentCount = selectedSortedTeams().filter({ + if type == .bracket { + return $0.wildCardBracket + } else { + return $0.wildCardGroupStage + } + }).count + + if currentCount < count { + let _diff = count - currentCount + addWildCard(_diff, type) + } + } + + func addWildCard(_ count: Int, _ type: MatchType) { + let wcs = (0.. Int { + let bracketTeamCount = teamCount - (teamsPerGroupStage - qualifiedPerGroupStage) * groupStageCount + (groupStageAdditionalQualified * (groupStageCount > 0 ? 1 : 0)) + return bracketTeamCount + } + + func buildBracket(minimalBracketTeamCount: Int? = nil) { + guard rounds().isEmpty else { return } + let roundCount = RoundRule.numberOfRounds(forTeams: minimalBracketTeamCount ?? bracketTeamCount()) + let matchCount = RoundRule.numberOfMatches(forTeams: minimalBracketTeamCount ?? bracketTeamCount()) + + let rounds = (0.. Match? { + guard let bracketPosition else { return nil } + let matchIndex = bracketPosition / 2 + let roundIndex = RoundRule.roundIndex(fromMatchIndex: matchIndex) + if let round: Round = self.getRound(atRoundIndex: roundIndex) { + return self.tournamentStore?.matches.first(where: { $0.round == round.id && $0.index == matchIndex }) +// return Store.main.filter(isIncluded: { $0.round == round.id && $0.index == matchIndex }).first + + } + return nil + } + + func resetTeamScores(in matchOfBracketPosition: Int?, outsideOf: [TeamScore] = []) { + guard let match = match(for: matchOfBracketPosition) else { return } + match.resetTeamScores(outsideOf: outsideOf) + } + + func updateTeamScores(in matchOfBracketPosition: Int?) { + guard let match = match(for: matchOfBracketPosition) else { return } + match.updateTeamScores() + } + + func deleteStructure() { + self.tournamentStore?.rounds.delete(contentOfs: rounds()) + } + + func resetBracketPosition() { + unsortedTeams().forEach({ $0.bracketPosition = nil }) + } + + func deleteGroupStages() { + self.tournamentStore?.groupStages.delete(contentOfs: allGroupStages()) + } + + func refreshGroupStages(keepExistingMatches: Bool = false) { + unsortedTeams().forEach { team in + team.groupStage = nil + team.groupStagePosition = nil + } + + if groupStageCount > 0 { + switch groupStageOrderingMode { + case .random: + setGroupStage(randomize: true, keepExistingMatches: keepExistingMatches) + case .snake: + setGroupStage(randomize: false, keepExistingMatches: keepExistingMatches) + case .swiss: + setGroupStage(randomize: true, keepExistingMatches: keepExistingMatches) + } + } + } + + func setGroupStage(randomize: Bool, keepExistingMatches: Bool = false) { + let groupStages = groupStages() + let numberOfBracketsAsInt = groupStages.count +// let teamsPerBracket = teamsPerBracket + if groupStageCount != numberOfBracketsAsInt { + deleteGroupStages() + buildGroupStages() + } else { + setGroupStageTeams(randomize: randomize) + groupStages.forEach { $0.buildMatches(keepExistingMatches: keepExistingMatches) } + } + } + + func removeWildCards() { + let wcs = unsortedTeams().filter({ $0.isWildCard() && $0.unsortedPlayers().isEmpty }) + do { + try tournamentStore?.teamRegistrations.delete(contentOfs: wcs) + } catch { + Logger.error(error) + } + } + + func setGroupStageTeams(randomize: Bool) { + let groupStages = groupStages() + let max = groupStages.map { $0.size }.reduce(0,+) + var chunks = selectedSortedTeams().filter({ $0.wildCardBracket == false }).suffix(max).chunked(into: groupStageCount) + for (index, _) in chunks.enumerated() { + if randomize { + chunks[index].shuffle() + } else if index % 2 != 0 { + chunks[index].reverse() + } + + print("Equipes \(chunks[index].map { $0.weight })") + for (jIndex, _) in chunks[index].enumerated() { + print("Position \(index + 1) Poule \(groupStages[jIndex].index)") + chunks[index][jIndex].groupStage = groupStages[jIndex].id + chunks[index][jIndex].groupStagePosition = index + } + } + + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams()) + } + + func isFree() -> Bool { + return entryFee == nil || entryFee == 0 + } + + func indexOf(team: TeamRegistration) -> Int? { + return selectedSortedTeams().firstIndex(where: { $0.id == team.id }) + } + + func labelIndexOf(team: TeamRegistration) -> String? { + if let teamIndex = indexOf(team: team) { + return "Tête de série #" + (teamIndex + 1).formatted() + } else { + return nil + } + } + + func addTeam(_ players: Set, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration { + let team = TeamRegistration(tournament: id, registrationDate: registrationDate, name: name) + team.setWeight(from: Array(players), inTournamentCategory: tournamentCategory) + players.forEach { player in + player.teamRegistration = team.id + } + if isAnimation() { + if team.weight == 0 { + team.weight = unsortedTeams().count + } + } + return team + } + + var matchFormat: MatchFormat { + get { + roundFormat ?? .defaultFormatForMatchType(.bracket) + } + set { + roundFormat = newValue + } + } + + var groupStageMatchFormat: MatchFormat { + get { + groupStageFormat ?? .defaultFormatForMatchType(.groupStage) + } + set { + groupStageFormat = newValue + } + } + + var loserBracketMatchFormat: MatchFormat { + get { + loserRoundFormat ?? .defaultFormatForMatchType(.loserBracket) + } + set { + loserRoundFormat = newValue + } + } + + var groupStageOrderingMode: GroupStageOrderingMode { + get { + groupStageSortMode + } + set { + groupStageSortMode = newValue + } + } + + var tournamentCategory: TournamentCategory { + get { + federalCategory + } + set { + if federalCategory != newValue { + federalCategory = newValue + updateWeights() + } else { + federalCategory = newValue + } + } + } + + var tournamentLevel: TournamentLevel { + get { + federalLevelCategory + } + set { + federalLevelCategory = newValue + teamSorting = newValue.defaultTeamSortingType + groupStageMatchFormat = groupStageSmartMatchFormat() + loserBracketMatchFormat = loserBracketSmartMatchFormat(1) + matchFormat = roundSmartMatchFormat(5) + } + } + + var federalTournamentAge: FederalTournamentAge { + get { + federalAgeCategory + } + set { + federalAgeCategory = newValue + } + } + + func loserBracketSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { + let format = tournamentLevel.federalFormatForLoserBracketRound(roundIndex) + if tournamentLevel == .p25 { return .superTie } + if format.rank < loserBracketMatchFormat.rank { + return format + } else { + return loserBracketMatchFormat + } + } + + func groupStageSmartMatchFormat() -> MatchFormat { + let format = tournamentLevel.federalFormatForGroupStage() + if tournamentLevel == .p25 { return .superTie } + if format.rank < groupStageMatchFormat.rank { + return format + } else { + return groupStageMatchFormat + } + } + + func initSettings(templateTournament: Tournament?) { + setupDefaultPrivateSettings(templateTournament: templateTournament) + setupUmpireSettings(defaultTournament: nil) //default is not template, default is for event sharing settings + if let templateTournament { + setupRegistrationSettings(templateTournament: templateTournament) + } + setupFederalSettings() + } + + func setupDefaultPrivateSettings(templateTournament: Tournament?) { +#if DEBUG + self.isPrivate = false + self.publishTeams = true + self.publishSummons = true + self.publishBrackets = true + self.publishGroupStages = true + self.publishRankings = true + self.publishTournament = true +#else + var shouldBePrivate = templateTournament?.isPrivate ?? true + + if Guard.main.currentPlan == .monthlyUnlimited { + shouldBePrivate = false + } else if Guard.main.purchasedTransactions.isEmpty == false { + shouldBePrivate = false + } + + self.isPrivate = shouldBePrivate +#endif + } + + func setupUmpireSettings(defaultTournament: Tournament? = nil) { + if let defaultTournament { + self.umpireCustomMail = defaultTournament.umpireCustomMail + self.umpireCustomPhone = defaultTournament.umpireCustomPhone + self.umpireCustomContact = defaultTournament.umpireCustomContact + self.hideUmpireMail = defaultTournament.hideUmpireMail + self.hideUmpirePhone = defaultTournament.hideUmpirePhone + self.disableRankingFederalRuling = defaultTournament.disableRankingFederalRuling + self.loserBracketMode = defaultTournament.loserBracketMode + } else { + let user = DataStore.shared.user + self.umpireCustomMail = user.umpireCustomMail + self.umpireCustomPhone = user.umpireCustomPhone + self.umpireCustomContact = user.umpireCustomContact + self.hideUmpireMail = user.hideUmpireMail + self.hideUmpirePhone = user.hideUmpirePhone + self.disableRankingFederalRuling = user.disableRankingFederalRuling + self.loserBracketMode = user.loserBracketMode + } + } + + func setupRegistrationSettings(templateTournament: Tournament) { + self.enableOnlineRegistration = templateTournament.enableOnlineRegistration + self.accountIsRequired = templateTournament.accountIsRequired + self.licenseIsRequired = templateTournament.licenseIsRequired + self.minimumPlayerPerTeam = templateTournament.minimumPlayerPerTeam + self.maximumPlayerPerTeam = templateTournament.maximumPlayerPerTeam + self.waitingListLimit = templateTournament.waitingListLimit + self.teamCountLimit = templateTournament.teamCountLimit + self.enableOnlinePayment = templateTournament.enableOnlinePayment + self.onlinePaymentIsMandatory = templateTournament.onlinePaymentIsMandatory + self.enableOnlinePaymentRefund = templateTournament.enableOnlinePaymentRefund + self.stripeAccountId = templateTournament.stripeAccountId + self.enableTimeToConfirm = templateTournament.enableTimeToConfirm + self.isCorporateTournament = templateTournament.isCorporateTournament + + if self.registrationDateLimit == nil, templateTournament.registrationDateLimit != nil { + self.registrationDateLimit = startDate.truncateMinutesAndSeconds() + } + self.openingRegistrationDate = templateTournament.openingRegistrationDate != nil ? creationDate.truncateMinutesAndSeconds() : nil + self.refundDateLimit = templateTournament.enableOnlinePaymentRefund ? startDate.truncateMinutesAndSeconds() : nil + } + + func setupFederalSettings() { + teamSorting = tournamentLevel.defaultTeamSortingType + groupStageMatchFormat = groupStageSmartMatchFormat() + loserBracketMatchFormat = loserBracketSmartMatchFormat(5) + matchFormat = roundSmartMatchFormat(5) + entryFee = tournamentLevel.entryFee + registrationDateLimit = deadline(for: .inscription) + if enableOnlineRegistration, isAnimation() == false { + accountIsRequired = true + licenseIsRequired = true + } + } + + func customizeUsingPreferences() { + guard let lastTournamentWithSameBuild = DataStore.shared.tournaments.filter({ tournament in + tournament.tournamentLevel == self.tournamentLevel + && tournament.tournamentCategory == self.tournamentCategory + && tournament.federalTournamentAge == self.federalTournamentAge + && tournament.hasEnded() == true + && tournament.isCanceled == false + && tournament.isDeleted == false + }).sorted(by: \.endDate!, order: .descending).first else { + return + } + + self.dayDuration = lastTournamentWithSameBuild.dayDuration + self.teamCount = (lastTournamentWithSameBuild.teamCount / 2) * 2 + self.enableOnlineRegistration = lastTournamentWithSameBuild.enableOnlineRegistration + } + + func onlineRegistrationCanBeEnabled() -> Bool { + true +// isAnimation() == false + } + + func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { + let format = tournamentLevel.federalFormatForBracketRound(roundIndex) + if tournamentLevel == .p25 { return .superTie } + if format.rank < matchFormat.rank { + return format + } else { + return matchFormat + } + } + + private func _defaultSorting() -> [MySortDescriptor] { + switch teamSorting { + case .rank: + [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.id)] + case .inscriptionDate: + [.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.id)] + } + } + + func isSameBuild(_ build: any TournamentBuildHolder) -> Bool { + tournamentLevel == build.level + && tournamentCategory == build.category + && federalTournamentAge == build.age + } + + private let _currentSelectionSorting : [MySortDescriptor] = [.keyPath(\.weight), .keyPath(\.id)] + + private func _matchSchedulers() -> [MatchScheduler] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matchSchedulers.filter { $0.tournament == self.id } +// DataStore.shared.matchSchedulers.filter(isIncluded: { $0.tournament == self.id }) + } + + func matchScheduler() -> MatchScheduler? { + return self._matchSchedulers().first + } + + func courtsAvailable() -> [Int] { + (0.. MonthData? { + guard let rankSourceDate else { return nil } + let dateString = URL.importDateFormatter.string(from: rankSourceDate) + return DataStore.shared.monthData.first(where: { $0.monthKey == dateString }) + } + + var maleUnrankedValue: Int? { + return currentMonthData()?.maleUnrankedValue + } + + var femaleUnrankedValue: Int? { + return currentMonthData()?.femaleUnrankedValue + } + + func courtNameIfAvailable(atIndex courtIndex: Int) -> String? { + return club()?.customizedCourts.first(where: { $0.index == courtIndex })?.name + } + + func courtName(atIndex courtIndex: Int) -> String { + return courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) + } + + func tournamentWinner() -> TeamRegistration? { + let finals: Round? = self.tournamentStore?.rounds.first(where: { $0.index == 0 && $0.isUpperBracket() }) + return finals?.playedMatches().first?.winner() + } + + func getGroupStageChunkValue() -> Int { + if groupStageCount > 0 && teamsPerGroupStage >= 2 { + let result = courtCount / (teamsPerGroupStage / 2) + let remainder = courtCount % (teamsPerGroupStage / 2) + let value = remainder == 0 ? result : result + 1 + return min(groupStageCount, value) + } else { + return 1 + } + } + + func replacementRangeExtended(groupStagePosition: Int) -> TeamRegistration.TeamRange? { + let selectedSortedTeams = selectedSortedTeams() + var left: TeamRegistration? = nil + if groupStagePosition == 0 { + left = seeds().last + } else { + let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition - 1 }).sorted(by: \.weight) + left = previousHat.last + } + var right: TeamRegistration? = nil + if groupStagePosition == teamsPerGroupStage - 1 { + right = nil + } else { + let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition + 1 }).sorted(by: \.weight) + right = previousHat.first + } + return (left: left, right: right) + } + + + typealias TeamPlacementIssue = (shouldBeInIt: [String], shouldNotBeInIt: [String]) + func groupStageTeamPlacementIssue() -> TeamPlacementIssue { + let selected = selectedSortedTeams() + let allTeams = unsortedTeams() + let newGroup = selected.suffix(groupStageSpots()) + let currentGroup = allTeams.filter({ $0.groupStagePosition != nil }) + let selectedIds = newGroup.map { $0.id } + let groupIds = currentGroup.map { $0.id } + let shouldBeInIt = Set(selectedIds).subtracting(groupIds) + let shouldNotBeInIt = Set(groupIds).subtracting(selectedIds) + return (Array(shouldBeInIt), Array(shouldNotBeInIt)) + } + + func bracketTeamPlacementIssue() -> TeamPlacementIssue { + let selected = selectedSortedTeams() + let allTeams = unsortedTeams() + let seedCount = max(selected.count - groupStageSpots(), 0) + let newGroup = selected.prefix(seedCount) + selected.filter({ $0.qualified }) + let currentGroup = allTeams.filter({ $0.bracketPosition != nil }) + let selectedIds = newGroup.map { $0.id } + let groupStageTeamsInBracket = selected.filter({ $0.qualified == false && $0.inGroupStage() && $0.inRound() }) + let groupIds = currentGroup.map { $0.id } + groupStageTeamsInBracket.map { $0.id } + let shouldBeInIt = Set(selectedIds).subtracting(groupIds) + let shouldNotBeInIt = Set(groupIds).subtracting(selectedIds) + return (Array(shouldBeInIt), Array(shouldNotBeInIt)) + } + + func groupStageLoserBracket() -> Round? { + self.tournamentStore?.rounds.first(where: { $0.groupStageLoserBracket }) + } + + func groupStageLoserBracketsInitialPlace() -> Int { + 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 + } + + func generateSmartLoserGroupStageBracket() { + guard let groupStageLoserBracket = groupStageLoserBracket() else { return } + for i in qualifiedPerGroupStage.. [Match] { + rounds().flatMap { $0.allLoserRoundMatches() } + } + + func seedsCount() -> Int { + selectedSortedTeams().count - groupStageSpots() + } + + func lastDrawnDate() -> Date? { + drawLogs().last?.drawDate + } + + func drawLogs() -> [DrawLog] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.drawLogs.sorted(by: \.drawDate) + } + + func seedSpotsLeft() -> Bool { + let alreadySeededRounds = rounds().filter({ $0.seeds().isEmpty == false }) + if alreadySeededRounds.isEmpty { return true } + let spotsLeft = alreadySeededRounds.flatMap({ $0.playedMatches() }).filter { $0.isEmpty() || $0.isValidSpot() } + + return spotsLeft.isEmpty == false + } + + func isRoundValidForSeeding(roundIndex: Int) -> Bool { + if let lastRoundWithSeeds = rounds().last(where: { $0.seeds().isEmpty == false }) { + return roundIndex >= lastRoundWithSeeds.index + } else { + return true + } + } + + + func updateSeedsBracketPosition() async { + await removeAllSeeds(saveTeamsAtTheEnd: false) + let drawLogs = drawLogs().reversed() + let seeds = seeds() + + await MainActor.run { + for (index, seed) in seeds.enumerated() { + if let drawLog = drawLogs.first(where: { $0.drawSeed == index }) { + drawLog.updateTeamBracketPosition(seed) + } + } + } + + do { + try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: seeds) + } catch { + Logger.error(error) + } + } + + func removeAllSeeds(saveTeamsAtTheEnd: Bool) async { + let teams = unsortedTeams() + teams.forEach({ team in + team.bracketPosition = nil + team._cachedRestingTime = nil + team.finalRanking = nil + team.pointsEarned = nil + }) + let allMatches = allRoundMatches() + let ts = allMatches.flatMap { match in + match.teamScores + } + allMatches.forEach { match in + match.disabled = false + match.losingTeamId = nil + match.winningTeamId = nil + match.endDate = nil + match.removeCourt() + match.servingTeamId = nil + } + + do { + try tournamentStore?.teamScores.delete(contentOfs: ts) + } catch { + Logger.error(error) + } + + if saveTeamsAtTheEnd { + do { + try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } catch { + Logger.error(error) + } + } + } + + func addNewRound(_ roundIndex: Int) async { + await MainActor.run { + let round = Round(tournament: id, index: roundIndex, matchFormat: matchFormat) + let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex) + let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex) + let nextRound = round.nextRound() + let tournamentStore = self.tournamentStore + var currentIndex = 0 + let matches = (0.. String { + var logs : [String] = ["Journal des tirages\n\n"] + logs.append(drawLogs().map { $0.exportedDrawLog() }.joined(separator: "\n\n")) + return logs.joined() + } + + + func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date) -> Bool { + guard let source = eventObject()?.courtsUnavailability else { return false } + let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex }) + return courtLockedSchedule.anySatisfy({ dateInterval in + let range = startDate.. OnlineRegistrationStatus { + if hasStarted() { + return .inProgress + } + if closedRegistrationDate != nil { + return .ended + } + if endDate != nil { + return .endedWithResults + } + + let now = Date() + + if let openingRegistrationDate = openingRegistrationDate { + let timezonedDateTime = openingRegistrationDate // Assuming dates are already in local timezone + if now < timezonedDateTime { + return .notStarted + } + } + + if let registrationDateLimit = registrationDateLimit { + let timezonedDateTime = registrationDateLimit // Assuming dates are already in local timezone + if now > timezonedDateTime { + return .ended + } + } + + let currentTeamCount = unsortedTeamsWithoutWO().count + + if currentTeamCount >= teamCount { + if let waitingListLimit = waitingListLimit { + let waitingListCount = currentTeamCount - teamCount + if waitingListCount >= waitingListLimit { + return .waitingListFull + } + } + return .waitingListPossible + } + + return .open + } + + // MARK: - Status + func shouldTournamentBeOver() async -> Bool { + if hasEnded() { + return true + } + if hasStarted() == false { + return false + } + if hasStarted(), self.startDate.timeIntervalSinceNow > -3600*24 { + return false + } + if tournamentStore?.store.fileCollectionsAllLoaded() == false { + return false + } +#if DEBUG //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func shouldTournamentBeOver()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + if isDeleted == false && hasEnded() == false && hasStarted() { + let allMatches = allMatches() + let remainingMatches = allMatches.filter({ $0.hasEnded() == false && $0.startDate != nil }) + + let calendar = Calendar.current + let anyTomorrow = remainingMatches.anySatisfy({ calendar.isDateInTomorrow($0.startDate!) }) + + + if anyTomorrow == false, let endDate = allMatches.filter({ $0.hasEnded() }).sorted(by: \.endDate!, order: .ascending).last?.endDate, endDate.timeIntervalSinceNow <= -2 * 3600 { + return true + } + } + + return false + } + + func rankSourceShouldBeRefreshed() -> Date? { + if let mostRecentDate = SourceFileManager.shared.lastDataSourceDate(), let currentRankSourceDate = rankSourceDate, currentRankSourceDate < mostRecentDate, hasEnded() == false { + return mostRecentDate + } else { + return nil + } + } + + func onlineTeams() -> [TeamRegistration] { + unsortedTeams().filter({ $0.hasRegisteredOnline() }) + } + + func paidOnlineTeams() -> [TeamRegistration] { + unsortedTeams().filter({ $0.hasPaidOnline() }) + } + + func shouldWarnOnlineRegistrationUpdates() -> Bool { + enableOnlineRegistration && onlineTeams().isEmpty == false && hasEnded() == false && hasStarted() == false + } + + func refreshTeamList(forced: Bool) async { + guard StoreCenter.main.isAuthenticated else { return } + guard tournamentStore?.store.fileCollectionsAllLoaded() == true else { return } + guard shouldRefreshTeams(forced: forced), refreshInProgress == false else { return } + if forced == false { + guard enableOnlineRegistration, hasEnded() == false else { + return + } + } + refreshInProgress = true + do { + try await self.tournamentStore?.playerRegistrations.loadDataFromServerIfAllowed(clear: true) + //try await self.tournamentStore?.teamScores.loadDataFromServerIfAllowed(clear: true) + try await self.tournamentStore?.teamRegistrations.loadDataFromServerIfAllowed(clear: true) + refreshInProgress = false + lastTeamRefresh = Date() + } catch { + Logger.error(error) + refreshInProgress = false + lastTeamRefresh = Date() + } + } + + func mailSubject() -> String { + let subject = [tournamentTitle(hideSenior: true), formattedDate(.short), clubName].compactMap({ $0 }).joined(separator: " | ") + return subject + } + + // MARK: - + + func insertOnServer() throws { + + DataStore.shared.tournaments.writeChangeAndInsertOnServer(instance: self) + + if let teamRegistrations = self.tournamentStore?.teamRegistrations { + for teamRegistration in teamRegistrations { + teamRegistration.insertOnServer() + } + } + + if let groupStages = self.tournamentStore?.groupStages { + for groupStage in groupStages { + groupStage.insertOnServer() + } + } + if let rounds = self.tournamentStore?.rounds { + for round in rounds { + round.insertOnServer() + } + } + + } + + func groupStageLosingPositions() -> [Int] { + guard let maxSize = groupStages().map({ $0.size }).max() else { + return [] + } + let leftInterval = qualifiedPerGroupStage + 1 + return Array(leftInterval...maxSize) + } + + // MARK: - Payments & Crypto + + enum PaymentError: Error { + case cantPayTournament + } + + func payIfNecessary() throws { + if self.payment != nil { return } + if let payment = Guard.main.paymentForNewTournament() { + self.payment = payment + DataStore.shared.tournaments.addOrUpdate(instance: self) + return + } + throw PaymentError.cantPayTournament + } + +} + +extension Bool { + var encodedValue: Int { + switch self { + case true: + return Int.random(in: (0...4)) + case false: + return Int.random(in: (5...9)) + } + } + static func decodeInt(_ int: Int) -> Bool { + switch int { + case (0...4): + return true + default: + return false + } + } +} + +//extension Tournament { +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _event = "event" +// case _name = "name" +// case _startDate = "startDate" +// case _endDate = "endDate" +// case _creationDate = "creationDate" +// case _isPrivate = "isPrivate" +// case _groupStageFormat = "groupStageFormat" +// case _roundFormat = "roundFormat" +// case _loserRoundFormat = "loserRoundFormat" +// case _groupStageSortMode = "groupStageSortMode" +// case _groupStageCount = "groupStageCount" +// case _rankSourceDate = "rankSourceDate" +// case _dayDuration = "dayDuration" +// case _teamCount = "teamCount" +// case _teamSorting = "teamSorting" +// case _federalCategory = "federalCategory" +// case _federalLevelCategory = "federalLevelCategory" +// case _federalAgeCategory = "federalAgeCategory" +// case _groupStageCourtCount = "groupStageCourtCount" +// case _closedRegistrationDate = "closedRegistrationDate" +// case _groupStageAdditionalQualified = "groupStageAdditionalQualified" +// case _courtCount = "courtCount" +// case _prioritizeClubMembers = "prioritizeClubMembers" +// case _qualifiedPerGroupStage = "qualifiedPerGroupStage" +// case _teamsPerGroupStage = "teamsPerGroupStage" +// case _entryFee = "entryFee" +// case _additionalEstimationDuration = "additionalEstimationDuration" +// case _isDeleted = "isDeleted" +// case _isCanceled = "localId" +// case _payment = "globalId" +// } +//} + +extension Tournament: FederalTournamentHolder { + func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String { + if isAnimation() { + if let name { + return name.trunc(length: DeviceHelper.charLength()) + } else if build.age == .unlisted, build.category == .unlisted { + return build.level.localizedLevelLabel(.title) + } else { + return build.level.localizedLevelLabel(displayStyle) + } + } + return build.level.localizedLevelLabel(displayStyle) + } + + var codeClub: String? { + club()?.code + } + + var holderId: String { id } + + func clubLabel() -> String { + locationLabel() + } + + func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String { + if isAnimation() { + if displayAgeAndCategory(forBuild: build) == false { + return [build.category.localizedCategoryLabel(ageCategory: build.age), build.age.localizedFederalAgeLabel()].filter({ $0.isEmpty == false }).joined(separator: " ") + } else if name != nil { + return build.level.localizedLevelLabel(.title) + } else { + return "" + } + } else { + return subtitle() + } + } + + var tournaments: [any TournamentBuildHolder] { + [ + self + ] + } + + var dayPeriod: DayPeriod { + let day = startDate.get(.weekday) + switch day { + case 2...6: + return .week + default: + return .weekend + } + } + + func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool { + if isAnimation() { + if let name, name.count < DeviceHelper.maxCharacter() { + return true + } else if build.age == .unlisted, build.category == .unlisted { + return true + } else { + return DeviceHelper.isBigScreen() + } + } + return true + } +} + +extension Tournament: TournamentBuildHolder { + func buildHolderTitle(_ displayStyle: DisplayStyle) -> String { + tournamentTitle(.short) + } + + var category: TournamentCategory { + tournamentCategory + } + + var level: TournamentLevel { + tournamentLevel + } + + var age: FederalTournamentAge { + federalTournamentAge + } +} + +extension Tournament { + static func getTemplateTournament() -> Tournament? { + return DataStore.shared.tournaments.filter { $0.isTemplate && $0.isDeleted == false }.sorted(by: \.startDate, order: .descending).first + } + + static func newEmptyInstance() -> Tournament { + let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource + var _mostRecentDateAvailable: Date? { + guard let lastDataSource else { return nil } + return URL.importDateFormatter.date(from: lastDataSource) + } + + let rankSourceDate = _mostRecentDateAvailable + return Tournament(rankSourceDate: rankSourceDate) + } + + static func fake() -> Tournament { + return Tournament(event: "Roland Garros", name: "Magic P100", startDate: Date(), endDate: Date(), creationDate: Date(), isPrivate: false, groupStageFormat: .nineGames, roundFormat: nil, loserRoundFormat: nil, groupStageSortMode: .snake, groupStageCount: 4, rankSourceDate: nil, dayDuration: 2, teamCount: 24, teamSorting: .rank, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .a45, closedRegistrationDate: nil, groupStageAdditionalQualified: 0, courtCount: 4, prioritizeClubMembers: false, qualifiedPerGroupStage: 2, teamsPerGroupStage: 4, entryFee: nil) + } + +} + +extension Tournament { + func deadline(for type: TournamentDeadlineType) -> Date? { + guard [.p500, .p1000, .p1500, .p2000].contains(tournamentLevel) else { return nil } + + let daysOffset = type.daysOffset(level: tournamentLevel) + if let date = Calendar.current.date(byAdding: .day, value: daysOffset, to: startDate) { + let startOfDay = Calendar.current.startOfDay(for: date) + return Calendar.current.date(byAdding: type.timeOffset, to: startOfDay) + } + return nil + } +} + +/// Warning: if the enum has more than 10 cases, the payment algo is broken +enum TournamentPayment: Int, CaseIterable { + case free, unit, subscriptionUnit, unlimited + + var isSubscription: Bool { + switch self { + case .subscriptionUnit, .unlimited: + return true + default: + return false + } + } + +} diff --git a/PadelClub/Views/Cashier/CashierDetailView.swift b/PadelClub/Views/Cashier/CashierDetailView.swift index fb975a4..cb7ccdd 100644 --- a/PadelClub/Views/Cashier/CashierDetailView.swift +++ b/PadelClub/Views/Cashier/CashierDetailView.swift @@ -11,6 +11,7 @@ import PadelClubData struct CashierDetailView: View { var tournaments : [Tournament] @State private var earnings: Double? = nil + @State private var remainingAmount: Double? = nil @State private var paidCompletion: Double? = nil init(tournaments: [Tournament]) { @@ -25,6 +26,16 @@ struct CashierDetailView: View { List { if tournaments.count > 1 { Section { + LabeledContent { + if let remainingAmount { + Text(remainingAmount.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + } else { + ProgressView() + } + } label: { + Text("Reste à encaisser") + } + LabeledContent { if let earnings { Text(earnings.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) @@ -58,10 +69,18 @@ struct CashierDetailView: View { if paidCompletion == nil { _getPaidCompletion() } + + if remainingAmount == nil { + _getRemainingAmount() + } } } } + private func _getRemainingAmount() { + remainingAmount = tournaments.map { $0.remainingAmount() }.reduce(0,+) + } + private func _getEarnings() { earnings = tournaments.map { $0.earnings() }.reduce(0,+) } @@ -89,11 +108,22 @@ struct CashierDetailView: View { let tournament: Tournament let showTournamentTitle: Bool @State private var earnings: Double? = nil + @State private var remainingAmount: Double? = nil @State private var paidCompletion: Double? = nil @State private var presence: Double? = nil var body: some View { Section { + LabeledContent { + if let remainingAmount { + Text(remainingAmount.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) + } else { + ProgressView() + } + } label: { + Text("Reste à encaisser") + } + LabeledContent { if let earnings { Text(earnings.formatted(.currency(code: Locale.defaultCurrency()).precision(.fractionLength(0)))) @@ -131,6 +161,10 @@ struct CashierDetailView: View { if presence == nil { presence = tournament.presenceStatus() } + + if remainingAmount == nil { + remainingAmount = tournament.remainingAmount() + } } } } diff --git a/PadelClub/Views/Tournament/ConsolationTournamentImportView.swift b/PadelClub/Views/Tournament/ConsolationTournamentImportView.swift index 50e5d1b..594a3da 100644 --- a/PadelClub/Views/Tournament/ConsolationTournamentImportView.swift +++ b/PadelClub/Views/Tournament/ConsolationTournamentImportView.swift @@ -17,6 +17,7 @@ struct ConsolationTournamentImportView: View { @State private var selectedTournament: Tournament? @State private var selectedImportSelector: ImportSelector = .groupStage @State private var selectedImportType: ImportType = .losers + @State private var selectedGroupStagePosition: Set = Set() enum ImportType: Int, Identifiable, CaseIterable { case losers @@ -135,6 +136,36 @@ struct ConsolationTournamentImportView: View { Text("Type") } } + + if selectedImportSelector == .groupStage, selectedImportType == .losers { + NavigationLink { + List(selection: $selectedGroupStagePosition) { + ForEach(selectedTournament.groupStageLosingPositions()) { position in + let text: String = "\(position)" + position.ordinalFormattedSuffix() + Text(text) + .tag(position) + } + } + .environment(\.editMode, Binding.constant(EditMode.active)) + .navigationTitle("Position des poules") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + } label: { + LabeledContent { + if selectedGroupStagePosition.isEmpty { + Text("Tous les perdants") + } else { + let value: String = selectedGroupStagePosition.map({ position in + let text: String = "\(position)" + position.ordinalFormattedSuffix() + return text + }).joined(separator: ", ") + Text(value) + } + } label: { + Text("Sélection des positions") + } + } + } } } @@ -155,16 +186,15 @@ struct ConsolationTournamentImportView: View { let teams = _teamsToImport(selectedTournament: selectedTournament) - Section { - RowButtonView("Importer les équipes") { - await _importTeams(selectedTournament: selectedTournament, teams: teams) - dismiss() - } - .disabled(teams.isEmpty) - } footer: { - if teams.isEmpty { - Text("Aucune équipe détectée").foregroundStyle(.logoRed) + if teams.isEmpty == false { + Section { + RowButtonView("Importer les \(teams.count) équipes") { + await _importTeams(selectedTournament: selectedTournament, teams: teams) + dismiss() + } } + } else { + Text("Aucune équipe à importer").foregroundStyle(.logoRed) } } } @@ -186,7 +216,13 @@ struct ConsolationTournamentImportView: View { teams = selectedTournament.groupStageTeams().filter({ switch selectedImportType { case .losers: - return $0.qualified == false + if selectedGroupStagePosition.isEmpty { + return $0.qualified == false + } else if let position = $0.groupStagePosition { + return $0.qualified == false && selectedGroupStagePosition.contains(position) + } else { + return $0.qualified == false + } case .winners: return $0.qualified case .all: diff --git a/PadelClub/Views/User/AccountView.swift b/PadelClub/Views/User/AccountView.swift index 1a35ece..922a9b8 100644 --- a/PadelClub/Views/User/AccountView.swift +++ b/PadelClub/Views/User/AccountView.swift @@ -36,10 +36,25 @@ struct AccountView: View { } } Section { - RowButtonView("Supprimer mon compte", role: .destructive, confirmationMessage: "Voulez-vous vraiment supprimer définitivement votre compte et ses données associées ?") { - DataStore.shared.deleteAccount() -// handler() + NavigationLink { + List { + Section { + Text("Attention la suppression de votre compte est irréversible. Vous perdrez l'accès à tous vos tournois créés jusqu'à présent.").foregroundStyle(.logoRed).bold() + } + + RowButtonView("Supprimer mon compte", role: .destructive, confirmationMessage: "Voulez-vous vraiment supprimer définitivement votre compte et ses données associées ?") { + DataStore.shared.deleteAccount() + } + } + .navigationTitle("Supprimer votre compte") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(.logoRed, for: .navigationBar) + } label: { + Text("Suppression du compte") } + } header: { + Text("Accéder à l'écran de suppression de compte.") } }.navigationTitle(user.username) }