You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
PadelClub/PadelClub/Data/Tournament.swift

1178 lines
45 KiB

//
// Tournament.swift
// PadelClub
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
import LeStorage
@Observable
class Tournament : ModelObject, Storable {
static func resourceName() -> String { "tournaments" }
var id: String = Store.randomId()
var event: String?
var creator: String?
var name: String?
var startDate: Date
var endDate: Date?
private(set) var creationDate: Date
var isPrivate: Bool
private var groupStageFormat: MatchFormat?
private var roundFormat: MatchFormat?
private var loserRoundFormat: MatchFormat?
var groupStageSortMode: GroupStageOrderingMode
var groupStageCount: Int
var rankSourceDate: Date?
var dayDuration: Int
var teamCount: Int
var teamSorting: TeamSortingType
var federalCategory: TournamentCategory
var federalLevelCategory: TournamentLevel
var federalAgeCategory: FederalTournamentAge
var groupStageCourtCount: Int?
var seedCount: Int
var closedRegistrationDate: Date?
var groupStageAdditionalQualified: Int
var courtCount: Int = 2
var prioritizeClubMembers: Bool
var qualifiedPerGroupStage: Int
var teamsPerGroupStage: Int
var entryFee: Double?
var payment: TournamentPayment? = nil
var additionalEstimationDuration: Int = 0
var isDeleted: Bool = false
var isCanceled: Bool = false
@ObservationIgnored
var navigationPath: [Screen] = []
internal init(event: String? = nil, creator: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = true, groupStageFormat: MatchFormat? = nil, roundFormat: MatchFormat? = nil, loserRoundFormat: MatchFormat? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType? = nil, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, groupStageCourtCount: Int? = nil, seedCount: Int = 8, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil) {
self.event = event
self.creator = creator
self.name = name
self.startDate = startDate
self.endDate = endDate
self.creationDate = creationDate
self.isPrivate = isPrivate
self.groupStageFormat = groupStageFormat
self.roundFormat = roundFormat
self.loserRoundFormat = loserRoundFormat
self.groupStageSortMode = groupStageSortMode
self.groupStageCount = groupStageCount
self.rankSourceDate = rankSourceDate
self.dayDuration = dayDuration
self.teamCount = teamCount
self.teamSorting = teamSorting ?? federalLevelCategory.defaultTeamSortingType
self.federalCategory = federalCategory
self.federalLevelCategory = federalLevelCategory
self.federalAgeCategory = federalAgeCategory
self.groupStageCourtCount = groupStageCourtCount
self.seedCount = seedCount
self.closedRegistrationDate = closedRegistrationDate
self.groupStageAdditionalQualified = groupStageAdditionalQualified
self.courtCount = courtCount
self.prioritizeClubMembers = prioritizeClubMembers
self.qualifiedPerGroupStage = qualifiedPerGroupStage
self.teamsPerGroupStage = teamsPerGroupStage
self.entryFee = entryFee
}
enum TournamentPayment: Int {
case free, unit, subscriptionUnit, unlimited
var isSubscription: Bool {
switch self {
case .subscriptionUnit, .unlimited:
return true
default:
return false
}
}
}
enum State {
case initial
case build
}
func courtUsed() -> [Int] {
let runningMatches : [Match] = Store.main.filter(isIncluded: { $0.isRunning() }).filter({ $0.tournamentId() == self.id })
return Set(runningMatches.compactMap { $0.courtIndex }).sorted()
}
func hasStarted() -> Bool {
startDate <= Date()
}
var eventObject: Event? {
guard let event else { return nil }
return Store.main.findById(event)
}
func pasteDataForImporting() -> String {
let selectedSortedTeams = selectedSortedTeams()
return (selectedSortedTeams.compactMap { $0.pasteData() } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams).compactMap { $0.pasteData() }).joined(separator: "\n\n")
}
func club() -> Club? {
eventObject?.clubObject
}
func locationLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if let club = club() {
switch displayStyle {
case .wide:
return club.name
case .short:
return club.acronym
}
} else {
return ""
}
}
func hasEnded() -> Bool {
endDate != nil
}
func state() -> Tournament.State {
if (groupStageCount > 0 && groupStages().isEmpty == false)
|| rounds().isEmpty == false {
return .build
}
return .initial
}
func seededTeams() -> [TeamRegistration] {
selectedSortedTeams().filter({ $0.bracketPosition != nil && $0.groupStagePosition == nil })
}
func groupStageTeams() -> [TeamRegistration] {
selectedSortedTeams().filter({ $0.bracketPosition == nil && $0.groupStagePosition != nil })
}
func seeds() -> [TeamRegistration] {
let seeds = max(teamCount - groupStageCount * teamsPerGroupStage, 0)
return Array(selectedSortedTeams().prefix(seeds))
}
func availableSeeds() -> [TeamRegistration] {
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? {
Store.main.filter(isIncluded: { $0.tournament == id && $0.index == roundIndex }).first
}
func availableSeedSpot(inRoundIndex roundIndex: Int) -> [Match] {
getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.teams().count == 0 } ?? []
}
func availableSeedOpponentSpot(inRoundIndex roundIndex: Int) -> [Match] {
getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.teams().count == 1 } ?? []
}
func availableSeedGroups() -> [SeedInterval] {
let seeds = seeds()
var availableSeedGroup = Set<SeedInterval>()
for (index, seed) in seeds.enumerated() {
if seed.isSeedable(), let seedGroup = seedGroup(for: index) {
availableSeedGroup.insert(seedGroup)
}
}
return availableSeedGroup.sorted(by: <)
}
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? {
if availableSeeds().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)
if availableSeeds.count == availableSeedSpot.count && availableSeedGroup.count == availableSeeds.count {
return availableSeedGroup
} else if (availableSeeds.count == availableSeedOpponentSpot.count && availableSeeds.count == self.availableSeeds().count) && availableSeedGroup.count == availableSeedOpponentSpot.count {
return availableSeedGroup
} else if let chunks = availableSeedGroup.chunks() {
if let chunk = chunks.first(where: { seedInterval in
seedInterval.first >= self.seededTeams().count
}) {
return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: chunk)
}
}
}
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 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 groupStages() -> [GroupStage] {
Store.main.filter { $0.tournament == self.id }.sorted(by: \.index)
}
func getActiveGroupStage() -> GroupStage? {
let groupStages = groupStages()
return groupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).first ?? groupStages.first
}
func getActiveRound(withSeeds: Bool = false) -> Round? {
let rounds = rounds()
let round = rounds.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).reversed().first ?? rounds.first
if withSeeds {
if round?.seeds().isEmpty == false {
return round
} else {
return nil
}
} else {
return round
}
}
func allRoundMatches() -> [Match] {
allRounds().flatMap { $0._matches() }
}
func allMatches() -> [Match] {
let unsortedGroupStages : [GroupStage] = Store.main.filter { $0.tournament == self.id }
let matches: [Match] = unsortedGroupStages.flatMap { $0._matches() } + allRoundMatches()
return matches.filter({ $0.disabled == false })
}
func _allMatchesIncludingDisabled() -> [Match] {
let unsortedGroupStages : [GroupStage] = Store.main.filter { $0.tournament == self.id }
return unsortedGroupStages.flatMap { $0._matches() } + allRounds().flatMap { $0._matches() }
}
func allRounds() -> [Round] {
Store.main.filter { $0.tournament == self.id }
}
func rounds() -> [Round] {
Store.main.filter { $0.tournament == self.id && $0.parent == nil }.sorted(by: \.index).reversed()
}
func sortedTeams() -> [TeamRegistration] {
let teams = selectedSortedTeams()
return teams + waitingListTeams(in: teams)
}
func selectedSortedTeams() -> [TeamRegistration] {
let start = Date()
var _sortedTeams : [TeamRegistration] = []
let _teams = unsortedTeams().filter({ $0.walkOut == false })
let defaultSorting : [MySortDescriptor<TeamRegistration>] = _defaultSorting()
let _completeTeams = _teams.sorted(using: defaultSorting, order: .ascending).filter { $0.isWildCard() == false}
let wcGroupStage = _teams.filter { $0.wildCardGroupStage }.sorted(using: _currentSelectionSorting, order: .ascending)
let wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending)
var bracketSeeds = min(teamCount, _completeTeams.count) - groupStageCount * teamsPerGroupStage - wcBracket.count
var groupStageTeamCount = groupStageCount * teamsPerGroupStage - wcGroupStage.count
if groupStageTeamCount < 0 { groupStageTeamCount = 0 }
if bracketSeeds < 0 { bracketSeeds = 0 }
if prioritizeClubMembers {
let bracketTeams = (_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)
}
//let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
//print("func selectedSortedTeams", id, tournamentTitle(), duration.formatted(.units(allowed: [.seconds, .milliseconds])))
return _sortedTeams
}
func waitingListTeams(in teams: [TeamRegistration]) -> [TeamRegistration] {
let waitingList = Set(unsortedTeams()).subtracting(teams)
return waitingList.filter { $0.walkOut == false }.sorted(using: _defaultSorting(), order: .ascending) + waitingList.filter { $0.walkOut == true }.sorted(using: _defaultSorting(), order: .ascending)
}
func bracketCut() -> Int {
max(0, teamCount - groupStageCut())
}
func groupStageCut() -> Int {
groupStageCount * teamsPerGroupStage
}
func cutLabel(index: Int) -> String {
if index < bracketCut() {
return "Tableau"
} else if index - bracketCut() < groupStageCut() {
return "Poule"
} else {
return "Liste d'attente"
}
}
func unsortedTeams() -> [TeamRegistration] {
Store.main.filter { $0.tournament == self.id }
}
func duplicates(in players: [PlayerRegistration]) -> [PlayerRegistration] {
var duplicates = [PlayerRegistration]()
Set(players.compactMap({ $0.licenceId })).forEach { licenceId in
let found = players.filter({ $0.licenceId == licenceId })
if found.count > 1 {
duplicates.append(found.first!)
}
}
return duplicates
}
func unsortedPlayers() -> [PlayerRegistration] {
unsortedTeams().flatMap { $0.unsortedPlayers() }
}
func selectedPlayers() -> [PlayerRegistration] {
selectedSortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.weight)
}
func players() -> [PlayerRegistration] {
unsortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.weight)
}
func femalePlayers() -> [PlayerRegistration] {
unsortedPlayers().filter({ $0.isMalePlayer() == false })
}
func unrankValue(for malePlayer: Bool) -> Int? {
switch tournamentCategory {
case .men:
return maleUnrankedValue
case .women:
return femaleUnrankedValue
case .mix:
return malePlayer ? maleUnrankedValue : femaleUnrankedValue
}
}
//todo
var clubName: String? {
eventObject?.clubObject?.name
}
//todo
func significantPlayerCount() -> Int {
2
}
func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
if player.rank == nil { return false }
if player.weight <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge) {
return true
} else {
return false
}
}
}
func mandatoryRegistrationCloseDate() -> Date? {
switch tournamentLevel {
case .p500, .p1000, .p1500, .p2000:
if let date = Calendar.current.date(byAdding: .day, value: -6, to: startDate) {
let startOfDay = Calendar.current.startOfDay(for: date)
return Calendar.current.date(byAdding: .minute, value: -1, to: startOfDay)
}
default:
break
}
return nil
}
func licenseYearValidity() -> Int {
if startDate.get(.month) > 8 {
return startDate.get(.year) + 1
} else {
return startDate.get(.year)
}
}
func playersWithoutValidLicense(in players: [PlayerRegistration]) -> [PlayerRegistration] {
let licenseYearValidity = licenseYearValidity()
return players.filter({
($0.isImported() && $0.isValidLicenseNumber(year: licenseYearValidity) == false) || ($0.isImported() == false && ($0.licenceId == nil || $0.formattedLicense().isLicenseNumber == false || $0.licenceId?.isEmpty == true))
})
}
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]()
teams.forEach { team in
if let previousTeam = team.previousTeam {
previousTeam.updatePlayers(team.players)
teamsToImport.append(previousTeam)
} else {
let newTeam = addTeam(team.players, registrationDate: team.registrationDate)
teamsToImport.append(newTeam)
}
}
try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: teamsToImport)
try? DataStore.shared.playerRegistrations.addOrUpdate(contentOfs: teams.flatMap { $0.players })
}
func maximumCourtsPerGroupSage() -> Int {
if teamsPerGroupStage > 1 {
return min(teamsPerGroupStage / 2, courtCount)
} else {
return max(1, courtCount)
}
}
func registrationIssues() -> Int {
let players : [PlayerRegistration] = unsortedPlayers()
let selectedTeams : [TeamRegistration] = selectedSortedTeams()
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) }
let duplicates : [PlayerRegistration] = duplicates(in: players)
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == -1 })
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players)
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players)
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 })
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams)
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
}
func isStartDateIsDifferentThanCallDate(_ team: TeamRegistration) -> Bool {
guard let callDate = team.callDate else { return true }
if let groupStageStartDate = team.groupStageObject()?.startDate {
return Calendar.current.compare(callDate, to: groupStageStartDate, toGranularity: .minute) != ComparisonResult.orderedSame
} else if let roundMatchStartDate = team.initialMatch()?.startDate {
return Calendar.current.compare(callDate, to: roundMatchStartDate, toGranularity: .minute) != ComparisonResult.orderedSame
}
return true
}
func availableToStart(_ allMatches: [Match]) -> [Match] {
let runningMatches = allMatches.filter({ $0.isRunning() && $0.isReady() })
return allMatches.filter({ $0.canBeStarted(inMatches: runningMatches) && $0.isRunning() == false }).sorted(by: \.computedStartDateForSorting)
}
func runningMatches(_ allMatches: [Match]) -> [Match] {
allMatches.filter({ $0.isRunning() && $0.isReady() }).sorted(by: \.computedStartDateForSorting)
}
func readyMatches(_ allMatches: [Match]) -> [Match] {
return allMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false }).sorted(by: \.computedStartDateForSorting)
}
func finishedMatches(_ allMatches: [Match], limit: Int? = nil) -> [Match] {
let _limit = limit ?? courtCount
return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed().prefix(_limit))
}
func lockRegistration() {
closedRegistrationDate = Date()
let count = selectedSortedTeams().count
if teamCount != count {
teamCount = count
}
let teams = unsortedTeams()
teams.forEach { team in
team.lockWeight = team.weight
}
try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: teams)
}
func updateWeights() {
let teams = self.unsortedTeams()
teams.forEach { team in
let players = team.unsortedPlayers()
players.forEach { $0.setWeight(in: self) }
team.setWeight(from: players)
try? DataStore.shared.playerRegistrations.addOrUpdate(contentOfs: players)
}
try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: teams)
}
func updateRank(to newDate: Date?) async throws {
guard let newDate else { return }
rankSourceDate = newDate
if currentMonthData() == nil {
let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate)
let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate)
await MainActor.run {
let monthData = MonthData(monthKey: URL.importDateFormatter.string(from: newDate))
monthData.maleUnrankedValue = lastRankMan
monthData.femaleUnrankedValue = lastRankWoman
try? DataStore.shared.monthData.addOrUpdate(instance: monthData)
}
}
let lastRankMan = currentMonthData()?.maleUnrankedValue
let lastRankWoman = currentMonthData()?.femaleUnrankedValue
try await unsortedPlayers().concurrentForEach { player in
let dataURLs = SourceFileManager.shared.allFiles.filter({ $0.dateFromPath == newDate })
let sources = dataURLs.map { CSVParser(url: $0) }
try await player.updateRank(from: sources, lastRank: (player.sex == 0 ? lastRankWoman : lastRankMan) ?? 0)
}
}
func missingUnrankedValue() -> Bool {
maleUnrankedValue == nil || femaleUnrankedValue == nil
}
func findTeam(_ players: [PlayerRegistration]) -> TeamRegistration? {
unsortedTeams().first(where: { $0.includes(players) })
}
func tournamentTitle(_ displayStyle: DisplayStyle = .wide) -> String {
[tournamentLevel.localizedLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle), name].compactMap({ $0 }).joined(separator: " ")
}
func subtitle(_ displayStyle: DisplayStyle = .wide) -> String {
name ?? ""
}
func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
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 {
groupStageCount * qualifiedPerGroupStage
}
func availableQualifiedTeams() -> [TeamRegistration] {
unsortedTeams().filter({ $0.qualified && $0.bracketPosition == nil })
}
func qualifiedTeams() -> [TeamRegistration] {
unsortedTeams().filter({ $0.qualifiedFromGroupStage() })
}
func moreQualifiedToDraw() -> Int {
max(qualifiedTeams().count - (qualifiedFromGroupStage() + groupStageAdditionalQualified), 0)
}
func missingQualifiedFromGroupStages() -> [TeamRegistration] {
if groupStageAdditionalQualified > 0 {
return groupStages().filter { $0.hasEnded() }.compactMap { groupStage in
groupStage.teams()[qualifiedPerGroupStage]
}
.filter({ $0.qualifiedFromGroupStage() == false })
} else {
return []
}
}
func groupStagesAreOver() -> Bool {
let groupStages = groupStages()
guard groupStages.isEmpty == false else {
return true
}
return groupStages.allSatisfy({ $0.hasEnded() })
//return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified
}
var entryFeeMessage: String {
if let entryFee {
return "Inscription: " + entryFee.formatted(.currency(code: "EUR")) + "."
} else {
return "Inscription: gratuite."
}
}
func umpireMail() -> [String]? {
if let email = DataStore.shared.user?.email {
return [email]
} else {
return nil
}
}
func earnings() -> Double {
if let entryFee {
return Double(selectedPlayers().filter { $0.hasPaid() }.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)
}
typealias TournamentStatus = (label:String, completion: String)
func cashierStatus() -> TournamentStatus {
let selectedPlayers = selectedPlayers()
let paid = selectedPlayers.filter({ $0.hasPaid() })
let label = paid.count.formatted() + " / " + selectedPlayers.count.formatted() + " joueurs encaissés"
let completion = (Double(paid.count) / Double(selectedPlayers.count))
let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0)))
return TournamentStatus(label: label, completion: completionLabel)
}
func scheduleStatus() -> TournamentStatus {
let allMatches = allMatches()
let ready = allMatches.filter({ $0.startDate != nil })
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() -> TournamentStatus {
let selectedSortedTeams = selectedSortedTeams()
let called = selectedSortedTeams.filter{ $0.called() }
let label = called.count.formatted() + " / " + selectedSortedTeams.count.formatted() + " paires convoqué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() -> String {
if let round = getActiveRound() {
return [round.roundTitle(), round.roundStatus()].joined(separator: " ")
} else {
return "à construire"
}
}
func groupStageStatus() -> String {
let runningGroupStages = groupStages().filter({ $0.isRunning() })
if groupStagesAreOver() { return "terminées" }
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"
}
return groupStages().count.formatted() + " poule" + groupStages().count.pluralSuffix
} else {
return "Poule" + runningGroupStages.count.pluralSuffix + " " + runningGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours"
}
}
func settingsDescriptionLocalizedLabel() -> String {
[dayDuration.formatted() + " jour\(dayDuration.pluralSuffix)", courtCount.formatted() + " terrain\(courtCount.pluralSuffix)"].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 buildStructure() {
deleteStructure()
deleteGroupStages()
buildGroupStages()
buildBracket()
}
func buildGroupStages() {
guard groupStages().isEmpty else {
return
}
var _groupStages = [GroupStage]()
for index in 0..<groupStageCount {
let groupStage = GroupStage(tournament: id, index: index, size: teamsPerGroupStage, matchFormat: groupStageMatchFormat)
_groupStages.append(groupStage)
}
try? DataStore.shared.groupStages.addOrUpdate(contentOfs: _groupStages)
groupStages().forEach { $0.buildMatches() }
refreshGroupStages()
}
func bracketTeamCount() -> Int {
let bracketTeamCount = teamCount - (teamsPerGroupStage - qualifiedPerGroupStage) * groupStageCount + (groupStageAdditionalQualified * (groupStageCount > 0 ? 1 : 0))
return bracketTeamCount
}
func buildBracket() {
guard rounds().isEmpty else { return }
let roundCount = RoundRule.numberOfRounds(forTeams: bracketTeamCount())
let rounds = (0..<roundCount).map { //index 0 is the final
Round(tournament: id, index: $0)
}
try? DataStore.shared.rounds.addOrUpdate(contentOfs: rounds)
let matchCount = RoundRule.numberOfMatches(forTeams: bracketTeamCount())
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: matchFormat)
}
print(matches.map {
(RoundRule.roundName(fromMatchIndex: $0.index), RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index))
})
try? DataStore.shared.matches.addOrUpdate(contentOfs: matches)
self.rounds().forEach { round in
round.buildLoserBracket()
}
}
func deleteStructure() {
try? DataStore.shared.rounds.delete(contentOfs: rounds())
unsortedTeams().forEach({ $0.bracketPosition = nil })
try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams())
}
func deleteGroupStages() {
try? DataStore.shared.groupStages.delete(contentOfs: groupStages())
unsortedTeams().forEach({
$0.groupStage = nil
$0.groupStagePosition = nil
})
try? DataStore.shared.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams())
}
func refreshGroupStages() {
unsortedTeams().forEach { team in
team.groupStage = nil
team.groupStagePosition = nil
}
if groupStageCount > 0 {
switch groupStageOrderingMode {
case .random:
setGroupStage(randomize: true)
case .snake:
setGroupStage(randomize: false)
case .swiss:
setGroupStage(randomize: true)
}
}
}
func setGroupStage(randomize: Bool) {
let groupStages = groupStages()
let numberOfBracketsAsInt = groupStages.count
// let teamsPerBracket = teamsPerBracket
if groupStageCount != numberOfBracketsAsInt {
deleteGroupStages()
buildGroupStages()
}
let max = groupStages.map { $0.size }.reduce(0,+)
var chunks = selectedSortedTeams().suffix(max).chunked(into: numberOfBracketsAsInt)
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
try? DataStore.shared.teamRegistrations.addOrUpdate(instance: chunks[index][jIndex])
}
}
}
func isFree() -> Bool {
entryFee == nil || entryFee == 0
}
func indexOf(team: TeamRegistration) -> Int? {
selectedSortedTeams().firstIndex(where: { $0.id == team.id })
}
func addTeam(_ players: Set<PlayerRegistration>, registrationDate: Date? = nil) -> TeamRegistration {
let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date())
team.tournamentCategory = tournamentCategory
team.setWeight(from: Array(players))
players.forEach { player in
player.teamRegistration = team.id
}
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(1)
}
}
var federalTournamentAge: FederalTournamentAge {
get {
federalAgeCategory
}
set {
federalAgeCategory = newValue
}
}
func loserBracketSmartMatchFormat(_ roundIndex: Int) -> MatchFormat {
let format = tournamentLevel.federalFormatForLoserBracketRound(roundIndex)
if loserBracketMatchFormat.rank > format.rank {
return format
} else {
return loserBracketMatchFormat
}
}
func groupStageSmartMatchFormat() -> MatchFormat {
let format = tournamentLevel.federalFormatForGroupStage()
if groupStageMatchFormat.rank > format.rank {
return format
} else {
return groupStageMatchFormat
}
}
func setupFederalSettings() {
teamSorting = tournamentLevel.defaultTeamSortingType
groupStageMatchFormat = groupStageSmartMatchFormat()
loserBracketMatchFormat = loserBracketSmartMatchFormat(1)
matchFormat = roundSmartMatchFormat(1)
}
func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat {
let format = tournamentLevel.federalFormatForBracketRound(roundIndex)
if matchFormat.rank > format.rank {
return format
} else {
return matchFormat
}
}
private func _defaultSorting() -> [MySortDescriptor<TeamRegistration>] {
switch teamSorting {
case .rank:
[.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.registrationDate!), .keyPath(\.canonicalName)]
case .inscriptionDate:
[.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight), .keyPath(\.canonicalName)]
}
}
func isSameBuild(_ build: any TournamentBuildHolder) -> Bool {
tournamentLevel == build.level
&& tournamentCategory == build.category
&& federalTournamentAge == build.age
}
private let _currentSelectionSorting : [MySortDescriptor<TeamRegistration>] = [.keyPath(\.weight), .keyPath(\.registrationDate!), .keyPath(\.canonicalName)]
override func deleteDependencies() throws {
try Store.main.deleteDependencies(items: self.unsortedTeams())
try Store.main.deleteDependencies(items: self.groupStages())
try Store.main.deleteDependencies(items: self.rounds())
}
func currentMonthData() -> MonthData? {
guard let rankSourceDate else { return nil }
let dateString = URL.importDateFormatter.string(from: rankSourceDate)
return Store.main.filter(isIncluded: { $0.monthKey == dateString }).first
}
var maleUnrankedValue: Int? {
currentMonthData()?.maleUnrankedValue
}
var femaleUnrankedValue: Int? {
currentMonthData()?.femaleUnrankedValue
}
func courtNameIfAvailable(atIndex courtIndex: Int) -> String? {
club()?.courts.first(where: { $0.index == courtIndex })?.name
}
func courtName(atIndex courtIndex: Int) -> String {
courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex)
}
}
extension Tournament {
enum CodingKeys: String, CodingKey {
case _id = "id"
case _event = "event"
case _creator = "creator"
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 _seedCount = "seedCount"
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 = "isCanceled"
}
}
extension Tournament: Hashable {
static func == (lhs: Tournament, rhs: Tournament) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension Tournament: FederalTournamentHolder {
var holderId: String { id }
func clubLabel() -> String {
locationLabel()
}
func subtitleLabel() -> String {
subtitle()
}
var tournaments: [any TournamentBuildHolder] {
[
self
]
}
}
extension Tournament: TournamentBuildHolder {
func buildHolderTitle() -> String {
tournamentTitle()
}
var category: TournamentCategory {
tournamentCategory
}
var level: TournamentLevel {
tournamentLevel
}
var age: FederalTournamentAge {
federalTournamentAge
}
}
extension Tournament {
static func fake() -> Tournament {
return Tournament(event: "Roland Garros", creator: "", 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, groupStageCourtCount: nil, seedCount: 8, closedRegistrationDate: nil, groupStageAdditionalQualified: 0, courtCount: 4, prioritizeClubMembers: false, qualifiedPerGroupStage: 2, teamsPerGroupStage: 4, entryFee: nil)
}
}