Compare commits

..

15 Commits

Author SHA1 Message Date
Raz 9b80437554 add u18 management 10 months ago
Raz f4718b8010 wip 10 months ago
Raz 7dc2619543 wip 10 months ago
Raz 472a456465 wip 10 months ago
Raz a206c351c3 fix issue with locationbutton 10 months ago
Raz 7a2cf4edea wip 10 months ago
Raz bd2ba72560 wip 11 months ago
Raz 354ae4c527 wip 11 months ago
Raz a6b4b46afa wip fix slow 11 months ago
Raz 28a0536248 fix progressview 11 months ago
Raz 7337767dd2 wip 11 months ago
Raz 99bf6c40d4 fix 11 months ago
Raz a2f0ef63e1 fix 11 months ago
Raz 7a71beeea8 fix issues 11 months ago
Raz 59fe22662b paca chpship 11 months ago
  1. 8
      CLAUDE.md
  2. 959
      PadelClub.xcodeproj/project.pbxproj
  3. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub ProdTest.xcscheme
  4. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  5. 4
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  6. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  7. 3
      PadelClub.xcworkspace/contents.xcworkspacedata
  8. 58
      PadelClub/AppDelegate.swift
  9. 33
      PadelClub/Assets.xcassets/beigeNotUniversal.colorset/Contents.json
  10. 33
      PadelClub/Assets.xcassets/grayNotUniversal.colorset/Contents.json
  11. 111
      PadelClub/Data/AppSettings.swift
  12. 203
      PadelClub/Data/Club.swift
  13. 3
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  14. 87
      PadelClub/Data/Court.swift
  15. 341
      PadelClub/Data/DataStore.swift
  16. 63
      PadelClub/Data/DateInterval.swift
  17. 158
      PadelClub/Data/DrawLog.swift
  18. 130
      PadelClub/Data/Event.swift
  19. 69
      PadelClub/Data/Federal/FederalPlayer.swift
  20. 258
      PadelClub/Data/Federal/FederalTournament.swift
  21. 2
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  22. 708
      PadelClub/Data/GroupStage.swift
  23. 1149
      PadelClub/Data/Match.swift
  24. 918
      PadelClub/Data/MatchScheduler.swift
  25. 66
      PadelClub/Data/MockData.swift
  26. 104
      PadelClub/Data/MonthData.swift
  27. 757
      PadelClub/Data/PlayerRegistration.swift
  28. 21
      PadelClub/Data/README.md
  29. 860
      PadelClub/Data/Round.swift
  30. 852
      PadelClub/Data/TeamRegistration.swift
  31. 98
      PadelClub/Data/TeamScore.swift
  32. 1962
      PadelClub/Data/Tournament.swift
  33. 61
      PadelClub/Data/TournamentStore.swift
  34. 250
      PadelClub/Data/User.swift
  35. 96
      PadelClub/Extensions/Array+Extensions.swift
  36. 25
      PadelClub/Extensions/Badge+Extensions.swift
  37. 71
      PadelClub/Extensions/Calendar+Extensions.swift
  38. 41
      PadelClub/Extensions/CodingContainer+Extensions.swift
  39. 22
      PadelClub/Extensions/CustomUser+Extensions.swift
  40. 262
      PadelClub/Extensions/Date+Extensions.swift
  41. 43
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  42. 28
      PadelClub/Extensions/Locale+Extensions.swift
  43. 49
      PadelClub/Extensions/MonthData+Extensions.swift
  44. 27
      PadelClub/Extensions/MySortDescriptor.swift
  45. 16
      PadelClub/Extensions/NumberFormatter+Extensions.swift
  46. 230
      PadelClub/Extensions/PlayerRegistration+Extensions.swift
  47. 33
      PadelClub/Extensions/Round+Extensions.swift
  48. 87
      PadelClub/Extensions/Sequence+Extensions.swift
  49. 34
      PadelClub/Extensions/SourceFileManager+Extensions.swift
  50. 42
      PadelClub/Extensions/SpinDrawable+Extensions.swift
  51. 47
      PadelClub/Extensions/String+Crypto.swift
  52. 286
      PadelClub/Extensions/String+Extensions.swift
  53. 84
      PadelClub/Extensions/TeamRegistration+Extensions.swift
  54. 428
      PadelClub/Extensions/Tournament+Extensions.swift
  55. 182
      PadelClub/Extensions/URL+Extensions.swift
  56. 22
      PadelClub/Extensions/View+Extensions.swift
  57. 5
      PadelClub/HTML Templates/bracket-template.html
  58. 1
      PadelClub/HTML Templates/groupstage-template.html
  59. 14
      PadelClub/HTML Templates/match-template.html
  60. 1
      PadelClub/HTML Templates/player-template.html
  61. 36
      PadelClub/HTML Templates/tournament-template.html
  62. 2
      PadelClub/Info.plist
  63. 60
      PadelClub/OnlineRegistrationWarningView.swift
  64. 182
      PadelClub/PadelClubApp.swift
  65. 69
      PadelClub/SyncedProducts.storekit
  66. 62
      PadelClub/Utils/CloudConvert.swift
  67. 254
      PadelClub/Utils/ContactManager.swift
  68. 12
      PadelClub/Utils/CryptoKey.swift
  69. 64
      PadelClub/Utils/DisplayContext.swift
  70. 38
      PadelClub/Utils/ExportFormat.swift
  71. 476
      PadelClub/Utils/FileImportManager.swift
  72. 23
      PadelClub/Utils/HtmlGenerator.swift
  73. 160
      PadelClub/Utils/HtmlService.swift
  74. 2
      PadelClub/Utils/LocationManager.swift
  75. 90
      PadelClub/Utils/Network/ConfigurationService.swift
  76. 302
      PadelClub/Utils/Network/FederalDataService.swift
  77. 200
      PadelClub/Utils/Network/NetworkFederalService.swift
  78. 5
      PadelClub/Utils/Network/NetworkManager.swift
  79. 28
      PadelClub/Utils/Network/NetworkManagerError.swift
  80. 77
      PadelClub/Utils/Network/PaymentService.swift
  81. 50
      PadelClub/Utils/Network/RefundService.swift
  82. 207
      PadelClub/Utils/Network/StripeValidationService.swift
  83. 47
      PadelClub/Utils/PListReader.swift
  84. 2032
      PadelClub/Utils/PadelRule.swift
  85. 164
      PadelClub/Utils/Patcher.swift
  86. 59
      PadelClub/Utils/PhoneNumbersUtils.swift
  87. 261
      PadelClub/Utils/SourceFileManager.swift
  88. 76
      PadelClub/Utils/SwiftParser.swift
  89. 177
      PadelClub/Utils/Tips.swift
  90. 86
      PadelClub/Utils/URLs.swift
  91. 33
      PadelClub/Utils/VersionComparator.swift
  92. 7
      PadelClub/ViewModel/AgendaDestination.swift
  93. 65
      PadelClub/ViewModel/FederalDataViewModel.swift
  94. 154
      PadelClub/ViewModel/MatchDescriptor.swift
  95. 3
      PadelClub/ViewModel/NavigationViewModel.swift
  96. 24
      PadelClub/ViewModel/Screen.swift
  97. 738
      PadelClub/ViewModel/SearchViewModel.swift
  98. 66
      PadelClub/ViewModel/SeedInterval.swift
  99. 89
      PadelClub/ViewModel/Selectable.swift
  100. 66
      PadelClub/ViewModel/SetDescriptor.swift
  101. Some files were not shown because too many files have changed in this diff Show More

@ -1,8 +0,0 @@
## Padel Club
This is the main directory of a Swift app that helps padel tournament organizers.
The project is structured around three projects linked in the PadelClub.xcworkspace:
- PadelClub: this one, which mostly contains the UI for the project
- PadelClubData: the business logic for the app
- LeStorage: a local storage with a synchronization layer

File diff suppressed because it is too large Load Diff

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -31,7 +31,7 @@
shouldAutocreateTestPlan = "YES"> shouldAutocreateTestPlan = "YES">
</TestAction> </TestAction>
<LaunchAction <LaunchAction
buildConfiguration = "Release" buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0" launchStyle = "0"

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1630" LastUpgradeVersion = "1600"
version = "1.7"> version = "1.7">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

@ -4,9 +4,6 @@
<FileRef <FileRef
location = "group:../LeStorage/LeStorage.xcodeproj"> location = "group:../LeStorage/LeStorage.xcodeproj">
</FileRef> </FileRef>
<FileRef
location = "container:../PadelClubData/PadelClubData.xcodeproj">
</FileRef>
<FileRef <FileRef
location = "group:PadelClub.xcodeproj"> location = "group:PadelClub.xcodeproj">
</FileRef> </FileRef>

@ -9,7 +9,6 @@ import Foundation
import UIKit import UIKit
import LeStorage import LeStorage
import UserNotifications import UserNotifications
import PadelClubData
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
@ -17,68 +16,17 @@ class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDel
// Override point for customization after application launch. // Override point for customization after application launch.
_ = Guard.main // init guard _ = Guard.main // init guard
self._configureLeStorage()
UIApplication.shared.registerForRemoteNotifications() UIApplication.shared.registerForRemoteNotifications()
UNUserNotificationCenter.current().delegate = self UNUserNotificationCenter.current().delegate = self
return true
}
fileprivate func _domain() -> String {
#if DEBUG
return "xlr.alwaysdata.net"
#elseif TESTFLIGHT
return "padelclub.app"
#elseif PRODTEST
return "padelclub.app"
#else
return "padelclub.app"
#endif
}
fileprivate func _configureLeStorage() {
StoreCenter.main.blackListUserName("apple-test")
StoreCenter.main.classProject = "PadelClubData"
// let secureScheme = true
let domain: String = self._domain()
#if DEBUG
if let secure = PListReader.readBool(plist: "local", key: "secure_server"),
let domain = PListReader.readString(plist: "local", key: "server_domain") {
StoreCenter.main.configureURLs(secureScheme: secure, domain: domain, webSockets: true, useSynchronization: true)
} else {
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
}
#else
StoreCenter.main.configureURLs(secureScheme: true, domain: domain, webSockets: true, useSynchronization: true)
#endif
StoreCenter.main.logsFailedAPICalls() Logger.log("didFinishLaunchingWithOptions")
return true
var synchronized: Bool = true
#if DEBUG
if let sync = PListReader.readBool(plist: "local", key: "synchronized") {
synchronized = sync
}
#endif
StoreCenter.main.forceNoSynchronization = !synchronized
}
func applicationWillEnterForeground(_ application: UIApplication) {
Task {
await Guard.main.refreshPurchases()
}
} }
// MARK: - Remote Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if StoreCenter.main.isAuthenticated { if StoreCenter.main.hasToken() {
Task { Task {
do { do {
let services = try StoreCenter.main.service() let services = try StoreCenter.main.service()

@ -1,33 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.808",
"green" : "0.906",
"red" : "0.980"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"platform" : "ios",
"reference" : "systemGrayColor"
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -1,33 +0,0 @@
{
"colors" : [
{
"color" : {
"platform" : "ios",
"reference" : "systemGrayColor"
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0xD2",
"red" : "0xFF"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

@ -0,0 +1,111 @@
//
// AppSettings.swift
// PadelClub
//
// Created by Razmig Sarkissian on 26/03/2024.
//
import Foundation
import LeStorage
import SwiftUI
@Observable
final class AppSettings: MicroStorable {
var lastDataSource: String? = nil
var didCreateAccount: Bool = false
var didRegisterAccount: Bool = false
//search tournament stuff
var tournamentCategories: Set<TournamentCategory.ID>
var tournamentLevels: Set<TournamentLevel.ID>
var tournamentAges: Set<FederalTournamentAge.ID>
var tournamentTypes: Set<FederalTournamentType.ID>
var startDate: Date
var endDate: Date
var city: String
var distance: Double
var sortingOption: String
var nationalCup: Bool
var dayDuration: Int?
var dayPeriod: DayPeriod
func lastDataSourceDate() -> Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
func localizedLastDataSource() -> String? {
guard let lastDataSource else { return nil }
guard let date = URL.importDateFormatter.date(from: lastDataSource) else { return nil }
return date.monthYearFormatted
}
func resetSearch() {
tournamentAges = Set()
tournamentTypes = Set()
tournamentLevels = Set()
tournamentCategories = Set()
city = ""
distance = 30
startDate = Date()
endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())!
sortingOption = "dateDebut+asc"
nationalCup = false
dayDuration = nil
dayPeriod = .all
}
required init() {
tournamentAges = Set()
tournamentTypes = Set()
tournamentLevels = Set()
tournamentCategories = Set()
city = ""
distance = 30
startDate = Date()
endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())!
sortingOption = "dateDebut+asc"
nationalCup = false
dayDuration = nil
dayPeriod = .all
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
lastDataSource = try container.decodeIfPresent(String.self, forKey: ._lastDataSource)
didCreateAccount = try container.decodeIfPresent(Bool.self, forKey: ._didCreateAccount) ?? false
didRegisterAccount = try container.decodeIfPresent(Bool.self, forKey: ._didRegisterAccount) ?? false
tournamentCategories = try container.decodeIfPresent(Set<TournamentCategory.ID>.self, forKey: ._tournamentCategories) ?? Set()
tournamentLevels = try container.decodeIfPresent(Set<TournamentLevel.ID>.self, forKey: ._tournamentLevels) ?? Set()
tournamentAges = try container.decodeIfPresent(Set<FederalTournamentAge.ID>.self, forKey: ._tournamentAges) ?? Set()
tournamentTypes = try container.decodeIfPresent(Set<FederalTournamentType.ID>.self, forKey: ._tournamentTypes) ?? Set()
startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? Date()
endDate = try container.decodeIfPresent(Date.self, forKey: ._endDate) ?? Calendar.current.date(byAdding: .month, value: 3, to: Date())!
city = try container.decodeIfPresent(String.self, forKey: ._city) ?? ""
distance = try container.decodeIfPresent(Double.self, forKey: ._distance) ?? 30
sortingOption = try container.decodeIfPresent(String.self, forKey: ._sortingOption) ?? "dateDebut+asc"
nationalCup = try container.decodeIfPresent(Bool.self, forKey: ._nationalCup) ?? false
dayDuration = try container.decodeIfPresent(Int.self, forKey: ._dayDuration)
dayPeriod = try container.decodeIfPresent(DayPeriod.self, forKey: ._dayPeriod) ?? .all
}
enum CodingKeys: String, CodingKey {
case _lastDataSource = "lastDataSource"
case _didCreateAccount = "didCreateAccount"
case _didRegisterAccount = "didRegisterAccount"
case _tournamentCategories = "tournamentCategories"
case _tournamentLevels = "tournamentLevels"
case _tournamentAges = "tournamentAges"
case _tournamentTypes = "tournamentTypes"
case _startDate = "startDate"
case _endDate = "endDate"
case _city = "city"
case _distance = "distance"
case _sortingOption = "sortingOption"
case _nationalCup = "nationalCup"
case _dayDuration = "dayDuration"
case _dayPeriod = "dayPeriod"
}
}

@ -0,0 +1,203 @@
//
// Club.swift
// PadelClub
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
import SwiftUI
import LeStorage
@Observable
final class Club : ModelObject, Storable, Hashable {
static func resourceName() -> String { return "clubs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [.get] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
static func == (lhs: Club, rhs: Club) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
return hasher.combine(id)
}
var id: String = Store.randomId()
var creator: String?
var name: String
var acronym: String
var phone: String?
var code: String?
//var federalClubData: Data?
var address: String?
var city: String?
var zipCode: String?
var latitude: Double?
var longitude: Double?
var courtCount: Int = 2
var broadcastCode: String?
var timezone: String?
// var alphabeticalName: Bool = false
internal init(creator: String? = nil, name: String, acronym: String? = nil, phone: String? = nil, code: String? = nil, address: String? = nil, city: String? = nil, zipCode: String? = nil, latitude: Double? = nil, longitude: Double? = nil, courtCount: Int = 2, broadcastCode: String? = nil, timezone: String? = nil) {
self.name = name
self.creator = creator
self.acronym = acronym ?? name.acronym()
self.phone = phone
self.code = code
self.address = address
self.city = city
self.zipCode = zipCode
self.latitude = latitude
self.longitude = longitude
self.courtCount = courtCount
self.broadcastCode = broadcastCode
self.timezone = TimeZone.current.identifier
}
override func copyFromServerInstance(_ instance: any Storable) -> Bool {
guard let copy = instance as? Club else { return false }
self.broadcastCode = copy.broadcastCode
// Logger.log("write code: \(self.broadcastCode)")
return true
}
func clubTitle(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
case .wide, .title:
return name
case .short:
return acronym
}
}
func shareURL() -> URL? {
return URL(string: URLs.main.url.appending(path: "?club=\(id)").absoluteString.removingPercentEncoding!)
}
var customizedCourts: [Court] {
DataStore.shared.courts.filter { $0.club == self.id }.sorted(by: \.index)
}
override func deleteDependencies() throws {
let customizedCourts = self.customizedCourts
for customizedCourt in customizedCourts {
try customizedCourt.deleteDependencies()
}
DataStore.shared.courts.deleteDependencies(customizedCourts)
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _creator = "creator"
case _name = "name"
case _acronym = "acronym"
case _phone = "phone"
case _code = "code"
case _address = "address"
case _city = "city"
case _zipCode = "zipCode"
case _latitude = "latitude"
case _longitude = "longitude"
case _courtCount = "courtCount"
case _broadcastCode = "broadcastCode"
case _timezone = "timezone"
// case _alphabeticalName = "alphabeticalName"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(creator, forKey: ._creator)
try container.encode(name, forKey: ._name)
try container.encode(acronym, forKey: ._acronym)
try container.encode(phone, forKey: ._phone)
try container.encode(code, forKey: ._code)
try container.encode(address, forKey: ._address)
try container.encode(city, forKey: ._city)
try container.encode(zipCode, forKey: ._zipCode)
try container.encode(latitude, forKey: ._latitude)
try container.encode(longitude, forKey: ._longitude)
try container.encode(courtCount, forKey: ._courtCount)
try container.encode(broadcastCode, forKey: ._broadcastCode)
try container.encode(timezone, forKey: ._timezone)
}
}
extension Club {
var isValid: Bool {
name.isEmpty == false && name.count > 3
}
func automaticShortName() -> String {
name.acronym()
}
enum AcronymMode: String, CaseIterable {
case automatic = "Automatique"
case custom = "Personalisée"
}
func shortNameMode() -> AcronymMode {
(acronym.isEmpty || acronym == automaticShortName()) ? .automatic : .custom
}
func hasTenupId() -> Bool {
code != nil
}
func federalLink() -> URL? {
guard let code else { return nil }
return URL(string: "https://tenup.fft.fr/club/\(code)")
}
func courtName(atIndex courtIndex: Int) -> String {
courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex)
}
func courtNameIfAvailable(atIndex courtIndex: Int) -> String? {
customizedCourts.first(where: { $0.index == courtIndex })?.name
}
func update(fromClub club: Club) {
self.acronym = club.acronym
self.name = club.name
self.phone = club.phone
self.code = club.code
self.address = club.address
self.city = club.city
self.zipCode = club.zipCode
self.latitude = club.latitude
self.longitude = club.longitude
}
func hasBeenCreated(by creatorId: String?) -> Bool {
return creatorId == creator || creator == nil
}
func isFavorite() -> Bool {
return DataStore.shared.user.clubs.contains(where: { $0 == self.id })
}
static func findOrCreate(name: String, code: String?, city: String? = nil, zipCode: String? = nil) -> Club {
/*
identify a club : code, name, ??
*/
let club: Club? = DataStore.shared.clubs.first(where: { (code == nil && $0.name == name && $0.city == city && $0.zipCode == zipCode) || code != nil && $0.code == code })
if let club {
return club
} else {
return Club(creator: StoreCenter.main.userId, name: name, code: code, city: city, zipCode: zipCode)
}
}
}

@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import PadelClubData
extension ImportedPlayer: PlayerHolder { extension ImportedPlayer: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? { func getAssimilatedAsMaleRank() -> Int? {
@ -132,6 +131,6 @@ extension ImportedPlayer: PlayerHolder {
fileprivate extension Int { fileprivate extension Int {
var femaleInMaleAssimilation: Int { var femaleInMaleAssimilation: Int {
self + TournamentCategory.femaleInMaleAssimilationAddition(self, seasonYear: Date.now.seasonYear()) self + TournamentCategory.femaleInMaleAssimilationAddition(self)
} }
} }

@ -0,0 +1,87 @@
//
// Court.swift
// PadelClub
//
// Created by Razmig Sarkissian on 23/04/2024.
//
import Foundation
import SwiftUI
import LeStorage
@Observable
final class Court : ModelObject, Storable, Hashable {
static func resourceName() -> String { return "courts" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
static func == (lhs: Court, rhs: Court) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
return hasher.combine(id)
}
var id: String = Store.randomId()
var index: Int
var club: String
var name: String?
var exitAllowed: Bool = false
var indoor: Bool = false
init(index: Int, club: String, name: String? = nil, exitAllowed: Bool = false, indoor: Bool = false) {
self.index = index
self.club = club
self.name = name
self.exitAllowed = exitAllowed
self.indoor = indoor
}
// internal init(club: String, name: String? = nil, index: Int) {
// self.club = club
// self.name = name
// self.index = index
// }
func courtTitle() -> String {
self.name ?? courtIndexTitle()
}
func courtIndexTitle() -> String {
Self.courtIndexedTitle(atIndex: index)
}
static func courtIndexedTitle(atIndex index: Int) -> String {
("Terrain #" + (index + 1).formatted())
}
func clubObject() -> Club? {
Store.main.findById(club)
}
override func deleteDependencies() throws {
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _index = "index"
case _club = "club"
case _name = "name"
case _exitAllowed = "exitAllowed"
case _indoor = "indoor"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(index, forKey: ._index)
try container.encode(club, forKey: ._club)
try container.encode(name, forKey: ._name)
try container.encode(exitAllowed, forKey: ._exitAllowed)
try container.encode(indoor, forKey: ._indoor)
}
}

@ -0,0 +1,341 @@
//
// DataStore.swift
// PadelClub
//
// Created by Laurent Morvillier on 02/02/2024.
//
import Foundation
import LeStorage
import SwiftUI
class DataStore: ObservableObject {
static let shared = DataStore()
@Published var user: User = User.placeHolder() {
didSet {
let loggedUser = StoreCenter.main.userId != nil
StoreCenter.main.collectionsCanSynchronize = loggedUser
if loggedUser {
if self.user.id != self.userStorage.item()?.id {
self.userStorage.setItemNoSync(self.user)
if StoreCenter.main.collectionsCanSynchronize {
Store.main.loadCollectionsFromServer()
self._fixMissingClubCreatorIfNecessary(self.clubs)
self._fixMissingEventCreatorIfNecessary(self.events)
}
}
} else {
self._temporaryLocalUser.item = self.user
}
}
}
fileprivate(set) var tournaments: StoredCollection<Tournament>
fileprivate(set) var clubs: StoredCollection<Club>
fileprivate(set) var courts: StoredCollection<Court>
fileprivate(set) var events: StoredCollection<Event>
fileprivate(set) var monthData: StoredCollection<MonthData>
fileprivate(set) var dateIntervals: StoredCollection<DateInterval>
fileprivate(set) var purchases: StoredCollection<Purchase>
fileprivate var userStorage: StoredSingleton<User>
fileprivate var _temporaryLocalUser: OptionalStorage<User> = OptionalStorage(fileName: "tmp_local_user.json")
fileprivate(set) var appSettingsStorage: MicroStorage<AppSettings> = MicroStorage(fileName: "appsettings.json")
var appSettings: AppSettings {
appSettingsStorage.item
}
init() {
let store = Store.main
let serverURL: String = URLs.api.rawValue
StoreCenter.main.blackListUserName("apple-test")
#if DEBUG
if let server = PListReader.readString(plist: "local", key: "server") {
StoreCenter.main.synchronizationApiURL = server
} else {
StoreCenter.main.synchronizationApiURL = serverURL
}
#else
StoreCenter.main.synchronizationApiURL = serverURL
#endif
StoreCenter.main.logsFailedAPICalls()
var synchronized: Bool = true
#if DEBUG
if let sync = PListReader.readBool(plist: "local", key: "synchronized") {
synchronized = sync
}
#endif
Logger.log("Sync URL: \(StoreCenter.main.synchronizationApiURL ?? "none"), sync: \(synchronized) ")
let indexed: Bool = true
self.clubs = store.registerCollection(synchronized: synchronized, indexed: indexed)
self.courts = store.registerCollection(synchronized: synchronized, indexed: indexed)
self.tournaments = store.registerCollection(synchronized: synchronized, indexed: indexed)
self.events = store.registerCollection(synchronized: synchronized, indexed: indexed)
self.monthData = store.registerCollection(synchronized: false, indexed: indexed)
self.dateIntervals = store.registerCollection(synchronized: synchronized, indexed: indexed)
self.userStorage = store.registerObject(synchronized: synchronized)
self.purchases = Store.main.registerCollection(synchronized: true, inMemory: true)
// Load ApiCallCollection, making them restart at launch and deletable on disconnect
StoreCenter.main.loadApiCallCollection(type: GroupStage.self)
StoreCenter.main.loadApiCallCollection(type: Round.self)
StoreCenter.main.loadApiCallCollection(type: PlayerRegistration.self)
StoreCenter.main.loadApiCallCollection(type: TeamRegistration.self)
StoreCenter.main.loadApiCallCollection(type: Match.self)
StoreCenter.main.loadApiCallCollection(type: TeamScore.self)
NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(collectionDidUpdate), name: NSNotification.Name.CollectionDidChange, object: nil)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func saveUser() {
do {
if user.username.count > 0 {
try self.userStorage.update()
} else {
self._temporaryLocalUser.item = self.user
}
} catch {
Logger.error(error)
}
}
@objc func collectionDidLoad(notification: Notification) {
DispatchQueue.main.async {
self.objectWillChange.send()
}
if let userSingleton: StoredSingleton<User> = notification.object as? StoredSingleton<User> {
self.user = userSingleton.item() ?? self._temporaryLocalUser.item ?? User.placeHolder()
} else if let clubsCollection: StoredCollection<Club> = notification.object as? StoredCollection<Club> {
self._fixMissingClubCreatorIfNecessary(clubsCollection)
} else if let eventsCollection: StoredCollection<Event> = notification.object as? StoredCollection<Event> {
self._fixMissingEventCreatorIfNecessary(eventsCollection)
}
if Store.main.collectionsAllLoaded() {
Patcher.applyAllWhenApplicable()
}
}
fileprivate func _fixMissingClubCreatorIfNecessary(_ clubsCollection: StoredCollection<Club>) {
do {
for club in clubsCollection {
if let userId = StoreCenter.main.userId, club.creator == nil {
club.creator = userId
self.userStorage.item()?.addClub(club)
try self.userStorage.update()
clubsCollection.writeChangeAndInsertOnServer(instance: club)
}
}
} catch {
Logger.error(error)
}
}
fileprivate func _fixMissingEventCreatorIfNecessary(_ eventsCollection: StoredCollection<Event>) {
for event in eventsCollection {
if let userId = StoreCenter.main.userId, event.creator == nil {
event.creator = userId
do {
try event.insertOnServer()
} catch {
Logger.error(error)
}
}
}
}
@objc func collectionDidUpdate(notification: Notification) {
self.objectWillChange.send()
}
func disconnect() {
Task {
if await StoreCenter.main.hasPendingAPICalls() {
// todo qu'est ce qu'on fait des API Call ?
}
do {
let services = try StoreCenter.main.service()
try await services.logout()
} catch {
Logger.error(error)
}
DispatchQueue.main.async {
self._localDisconnect()
}
}
}
func deleteAccount() {
Task {
do {
let services = try StoreCenter.main.service()
try await services.deleteAccount()
} catch {
Logger.error(error)
}
DispatchQueue.main.async {
self._localDisconnect()
}
}
}
fileprivate func _localDisconnect() {
StoreCenter.main.collectionsCanSynchronize = false
let tournamendIds: [String] = self.tournaments.map { $0.id }
self.tournaments.reset()
self.clubs.reset()
self.courts.reset()
self.events.reset()
self.dateIntervals.reset()
self.userStorage.reset()
self.purchases.reset()
Guard.main.disconnect()
StoreCenter.main.disconnect()
self.user = self._temporaryLocalUser.item ?? User.placeHolder()
self.user.clubs.removeAll()
// done after because otherwise folders remain
for tournament in tournamendIds {
StoreCenter.main.destroyStore(identifier: tournament.id)
}
}
func copyToLocalServer(tournament: Tournament) {
Task {
do {
if let url = PListReader.readString(plist: "local", key: "local_server"),
let login = PListReader.readString(plist: "local", key: "username"),
let pass = PListReader.readString(plist: "local", key: "password") {
let service = Services(url: url)
let _: User = try await service.login(username: login, password: pass)
tournament.event = nil
_ = try await service.post(tournament)
for groupStage in tournament.groupStages() {
_ = try await service.post(groupStage)
}
for round in tournament.rounds() {
try await self._insertRoundAndChildren(round: round, service: service)
}
for teamRegistration in tournament.unsortedTeams() {
_ = try await service.post(teamRegistration)
for playerRegistration in teamRegistration.unsortedPlayers() {
_ = try await service.post(playerRegistration)
}
}
for groupStage in tournament.groupStages() {
for match in groupStage._matches() {
try await self._insertMatch(match: match, service: service)
}
}
for round in tournament.allRounds() {
for match in round._matches() {
try await self._insertMatch(match: match, service: service)
}
}
}
} catch {
Logger.error(error)
}
}
}
fileprivate func _insertRoundAndChildren(round: Round, service: Services) async throws {
_ = try await service.post(round)
for loserRound in round.loserRounds() {
try await self._insertRoundAndChildren(round: loserRound, service: service)
}
}
fileprivate func _insertMatch(match: Match, service: Services) async throws {
_ = try await service.post(match)
for teamScore in match.teamScores {
_ = try await service.post(teamScore)
}
}
// MARK: - Convenience
func runningMatches() -> [Match] {
let dateNow : Date = Date()
let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
var runningMatches: [Match] = []
for tournament in lastTournaments {
let matches = tournament.tournamentStore.matches.filter { match in
match.disabled == false && match.isRunning()
}
runningMatches.append(contentsOf: matches)
}
return runningMatches
}
func runningAndNextMatches() -> [Match] {
let dateNow : Date = Date()
let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
var runningMatches: [Match] = []
for tournament in lastTournaments {
let matches = tournament.tournamentStore.matches.filter { match in
match.disabled == false && match.startDate != nil && match.endDate == nil }
runningMatches.append(contentsOf: matches)
}
return runningMatches
}
func endMatches() -> [Match] {
let dateNow : Date = Date()
let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
var runningMatches: [Match] = []
for tournament in lastTournaments {
let matches = tournament.tournamentStore.matches.filter { match in
match.disabled == false && match.hasEnded() }
runningMatches.append(contentsOf: matches)
}
return runningMatches.sorted(by: \.endDate!, order: .descending)
}
}

@ -0,0 +1,63 @@
//
// DateInterval.swift
// PadelClub
//
// Created by Razmig Sarkissian on 19/04/2024.
//
import Foundation
import SwiftUI
import LeStorage
@Observable
final class DateInterval: ModelObject, Storable {
static func resourceName() -> String { return "date-intervals" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var event: String
var courtIndex: Int
var startDate: Date
var endDate: Date
internal init(event: String, courtIndex: Int, startDate: Date, endDate: Date) {
self.event = event
self.courtIndex = courtIndex
self.startDate = startDate
self.endDate = endDate
}
var range: Range<Date> {
startDate..<endDate
}
func isSingleDay() -> Bool {
Calendar.current.isDate(startDate, inSameDayAs: endDate)
}
func isDateInside(_ date: Date) -> Bool {
date >= startDate && date <= endDate
}
func isDateOutside(_ date: Date) -> Bool {
date <= startDate && date <= endDate && date >= startDate && date >= endDate
}
override func deleteDependencies() throws {
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _event = "event"
case _courtIndex = "courtIndex"
case _startDate = "startDate"
case _endDate = "endDate"
}
func insertOnServer() throws {
try DataStore.shared.dateIntervals.writeChangeAndInsertOnServer(instance: self)
}
}

@ -0,0 +1,158 @@
//
// DrawLog.swift
// PadelClub
//
// Created by razmig on 22/10/2024.
//
import Foundation
import SwiftUI
import LeStorage
@Observable
final class DrawLog: ModelObject, Storable {
static func resourceName() -> String { return "draw-logs" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var tournament: String
var drawDate: Date = Date()
var drawSeed: Int
var drawMatchIndex: Int
var drawTeamPosition: TeamPosition
var drawType: DrawType
internal init(id: String = Store.randomId(), tournament: String, drawDate: Date = Date(), drawSeed: Int, drawMatchIndex: Int, drawTeamPosition: TeamPosition, drawType: DrawType) {
self.id = id
self.tournament = tournament
self.drawDate = drawDate
self.drawSeed = drawSeed
self.drawMatchIndex = drawMatchIndex
self.drawTeamPosition = drawTeamPosition
self.drawType = drawType
}
func tournamentObject() -> Tournament? {
Store.main.findById(self.tournament)
}
func computedBracketPosition() -> Int {
drawMatchIndex * 2 + drawTeamPosition.rawValue
}
func updateTeamBracketPosition(_ team: TeamRegistration) {
guard let match = drawMatch() else { return }
let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: drawTeamPosition)
team.bracketPosition = seedPosition
tournamentObject()?.updateTeamScores(in: seedPosition)
}
func exportedDrawLog() -> String {
[drawType.localizedDrawType(), drawDate.localizedDate(), localizedDrawLogLabel(), localizedDrawBranch()].filter({ $0.isEmpty == false }).joined(separator: " ")
}
func localizedDrawSeedLabel() -> String {
return "\(drawType.localizedDrawType()) #\(drawSeed + 1)"
}
func localizedDrawLogLabel() -> String {
return [localizedDrawSeedLabel(), positionLabel()].filter({ $0.isEmpty == false }).joined(separator: " -> ")
}
func localizedDrawBranch() -> String {
switch drawType {
case .seed:
return drawTeamPosition.localizedBranchLabel()
default:
return ""
}
}
func drawMatch() -> Match? {
switch drawType {
case .seed:
let roundIndex = RoundRule.roundIndex(fromMatchIndex: drawMatchIndex)
return tournamentStore.rounds.first(where: { $0.parent == nil && $0.index == roundIndex })?._matches().first(where: { $0.index == drawMatchIndex })
default:
return nil
}
}
func positionLabel() -> String {
return drawMatch()?.roundAndMatchTitle() ?? ""
}
func roundLabel() -> String {
return drawMatch()?.roundTitle() ?? ""
}
func matchLabel() -> String {
return drawMatch()?.matchTitle() ?? ""
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
override func deleteDependencies() throws {
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"
case _drawDate = "drawDate"
case _drawSeed = "drawSeed"
case _drawMatchIndex = "drawMatchIndex"
case _drawTeamPosition = "drawTeamPosition"
case _drawType = "drawType"
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: ._id)
tournament = try container.decode(String.self, forKey: ._tournament)
drawDate = try container.decode(Date.self, forKey: ._drawDate)
drawSeed = try container.decode(Int.self, forKey: ._drawSeed)
drawMatchIndex = try container.decode(Int.self, forKey: ._drawMatchIndex)
drawTeamPosition = try container.decode(TeamPosition.self, forKey: ._drawTeamPosition)
drawType = try container.decodeIfPresent(DrawType.self, forKey: ._drawType) ?? .seed
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(tournament, forKey: ._tournament)
try container.encode(drawDate, forKey: ._drawDate)
try container.encode(drawSeed, forKey: ._drawSeed)
try container.encode(drawMatchIndex, forKey: ._drawMatchIndex)
try container.encode(drawTeamPosition, forKey: ._drawTeamPosition)
try container.encode(drawType, forKey: ._drawType)
}
func insertOnServer() throws {
self.tournamentStore.drawLogs.writeChangeAndInsertOnServer(instance: self)
}
}
enum DrawType: Int, Codable {
case seed
case groupStage
case court
func localizedDrawType() -> String {
switch self {
case .seed:
return "Tête de série"
case .groupStage:
return "Poule"
case .court:
return "Terrain"
}
}
}

@ -0,0 +1,130 @@
//
// Event_v2.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
import SwiftUI
@Observable
final class Event: ModelObject, Storable {
static func resourceName() -> String { return "events" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var creator: String?
var club: String?
var creationDate: Date = Date()
var name: String?
var tenupId: String?
internal init(creator: String? = nil, club: String? = nil, name: String? = nil, tenupId: String? = nil) {
self.creator = creator
self.club = club
self.name = name
self.tenupId = tenupId
}
override func deleteDependencies() throws {
let tournaments = self.tournaments
for tournament in tournaments {
try tournament.deleteDependencies()
}
DataStore.shared.tournaments.deleteDependencies(tournaments)
let courtsUnavailabilities = self.courtsUnavailability
for courtsUnavailability in courtsUnavailabilities {
try courtsUnavailability.deleteDependencies()
}
DataStore.shared.dateIntervals.deleteDependencies(courtsUnavailabilities)
}
// MARK: - Computed dependencies
var tournaments: [Tournament] {
DataStore.shared.tournaments.filter { $0.event == self.id && $0.isDeleted == false }
}
func clubObject() -> Club? {
guard let club else { return nil }
return Store.main.findById(club)
}
var courtsUnavailability: [DateInterval] {
DataStore.shared.dateIntervals.filter({ $0.event == id })
}
// MARK: -
func eventCourtCount() -> Int {
tournaments.map { $0.courtCount }.max() ?? 2
}
func eventStartDate() -> Date {
tournaments.map { $0.startDate }.min() ?? Date()
}
func eventDayDuration() -> Int {
tournaments.map { $0.dayDuration }.max() ?? 1
}
func eventTitle() -> String {
if let name, name.isEmpty == false {
return name
} else {
return "Événement"
}
}
func existingBuild(_ build: any TournamentBuildHolder) -> Tournament? {
tournaments.first(where: { $0.isSameBuild(build) })
}
func tournamentsCourtsUsed(exluding tournamentId: String) -> [DateInterval] {
tournaments.filter { $0.id != tournamentId }.flatMap({ tournament in
tournament.getPlayedMatchDateIntervals(in: self)
})
}
func insertOnServer() throws {
try DataStore.shared.events.writeChangeAndInsertOnServer(instance: self)
for tournament in self.tournaments {
try tournament.insertOnServer()
}
for dataInterval in self.courtsUnavailability {
try dataInterval.insertOnServer()
}
}
}
extension Event {
enum CodingKeys: String, CodingKey {
case _id = "id"
case _creator = "creator"
case _club = "club"
case _creationDate = "creationDate"
case _name = "name"
case _tenupId = "tenupId"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(creator, forKey: ._creator)
try container.encode(club, forKey: ._club)
try container.encode(creationDate, forKey: ._creationDate)
try container.encode(name, forKey: ._name)
try container.encode(tenupId, forKey: ._tenupId)
}
}

@ -31,75 +31,54 @@ class FederalPlayer: Decodable {
} }
required init(from decoder: Decoder) throws { required init(from decoder: Decoder) throws {
/*
"classement": 9,
"evolution": 2,
"nom": "PEREZ LE TIEC",
"prenom": "Pierre",
"meilleurClassement": null,
"nationalite": "FRA",
"ageSportif": 30,
"points": 14210,
"nombreTournoisJoues": 24,
"ligue": "ILE DE FRANCE",
"assimilation": false
*/
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case nom case nom
case prenom case prenom
case licence case licence
case meilleurClassement case meilleurClassement
case nationalite case nationnalite
case anneeNaissance
case codeClub case codeClub
case nomClub case nomClub
case ligue case nomLigue
case classement case rang
case evolution case progression
case points case points
case nombreTournoisJoues case nombreDeTournois
case assimilation case assimile
case ageSportif
} }
let container = try decoder.container(keyedBy: CodingKeys.self) let container = try decoder.container(keyedBy: CodingKeys.self)
isMale = (decoder.userInfo[.maleData] as? Bool) == true isMale = (decoder.userInfo[.maleData] as? Bool) == true
let _lastName = try container.decodeIfPresent(String.self, forKey: .nom) let _lastName = try container.decode(String.self, forKey: .nom)
let _firstName = try container.decodeIfPresent(String.self, forKey: .prenom) let _firstName = try container.decode(String.self, forKey: .prenom)
lastName = _lastName ?? "" lastName = _lastName
firstName = _firstName ?? "" firstName = _firstName
if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) { if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) {
license = String(lic) license = String(lic)
} else { } else {
license = "" license = ""
} }
country = try container.decodeIfPresent(String.self, forKey: .nationalite) ?? "" let nationnalite = try container.decode(Nationnalite.self, forKey: .nationnalite)
country = nationnalite.code
bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement) bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement)
birthYear = try container.decodeIfPresent(Int.self, forKey: .anneeNaissance)
let ageSportif = try container.decodeIfPresent(Int.self, forKey: .ageSportif) clubCode = try container.decode(String.self, forKey: .codeClub)
if let ageSportif { club = try container.decode(String.self, forKey: .nomClub)
let month = Calendar.current.component(.month, from: Date()) ligue = try container.decode(String.self, forKey: .nomLigue)
if month > 8 { rank = try container.decode(Int.self, forKey: .rang)
birthYear = Calendar.current.component(.year, from: Date()) + 1 - ageSportif progression = (try? container.decodeIfPresent(Int.self, forKey: .progression)) ?? 0
} else {
birthYear = Calendar.current.component(.year, from: Date()) - ageSportif
}
}
clubCode = try container.decodeIfPresent(String.self, forKey: .codeClub) ?? ""
club = try container.decodeIfPresent(String.self, forKey: .nomClub) ?? ""
ligue = try container.decodeIfPresent(String.self, forKey: .ligue) ?? ""
rank = try container.decode(Int.self, forKey: .classement)
progression = (try? container.decodeIfPresent(Int.self, forKey: .evolution)) ?? 0
let pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points) let pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points)
if let pointsAsInt { if let pointsAsInt {
points = Double(pointsAsInt) points = Double(pointsAsInt)
} else { } else {
points = nil points = nil
} }
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreTournoisJoues) tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreDeTournois)
let assimile = try container.decode(Bool.self, forKey: .assimilation) let assimile = try container.decode(Bool.self, forKey: .assimile)
assimilation = assimile ? "Oui" : "Non" assimilation = assimile ? "Oui" : "Non"
} }
@ -113,7 +92,6 @@ class FederalPlayer: Decodable {
} }
func formatNumbers(_ input: String) -> String { func formatNumbers(_ input: String) -> String {
if input.isEmpty { return input }
// Insert spaces at appropriate positions // Insert spaces at appropriate positions
let formattedString = insertSeparator(input, separator: " ", every: [2, 4]) let formattedString = insertSeparator(input, separator: " ", every: [2, 4])
return formattedString return formattedString
@ -219,6 +197,8 @@ class FederalPlayer: Decodable {
} }
lastPlayerFetch.predicate = predicate lastPlayerFetch.predicate = predicate
let count = try? context.count(for: lastPlayerFetch) let count = try? context.count(for: lastPlayerFetch)
print("count", count)
do { do {
if let lr = try context.fetch(lastPlayerFetch).first?.rank { if let lr = try context.fetch(lastPlayerFetch).first?.rank {
let fetch = ImportedPlayer.fetchRequest() let fetch = ImportedPlayer.fetchRequest()
@ -227,9 +207,8 @@ class FederalPlayer: Decodable {
rankPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [rankPredicate, NSPredicate(format: "importDate == %@", mostRecentDateAvailable as CVarArg)]) rankPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [rankPredicate, NSPredicate(format: "importDate == %@", mostRecentDateAvailable as CVarArg)])
} }
fetch.predicate = rankPredicate fetch.predicate = rankPredicate
print(fetch.predicate)
let lastPlayersCount = try context.count(for: fetch) let lastPlayersCount = try context.count(for: fetch)
print(Int(lr), Int(lastPlayersCount) - 1, count)
return (Int(lr) + Int(lastPlayersCount) - 1, count) return (Int(lr) + Int(lastPlayersCount) - 1, count)
} }
} catch { } catch {

@ -6,11 +6,28 @@
import Foundation import Foundation
import CoreLocation import CoreLocation
import LeStorage import LeStorage
import PadelClubData
enum DayPeriod: Int, CaseIterable, Identifiable, Codable {
var id: Int { self.rawValue }
case all
case weekend
case week
func localizedDayPeriodLabel() -> String {
switch self {
case .all:
return "n'importe"
case .week:
return "la semaine"
case .weekend:
return "le week-end"
}
}
}
// MARK: - FederalTournament // MARK: - FederalTournament
struct FederalTournament: Identifiable, Codable, Hashable { struct FederalTournament: Identifiable, Codable {
func getEvent() -> Event { func getEvent() -> Event {
let club = DataStore.shared.user.clubsObjects().first(where: { $0.code == codeClub }) let club = DataStore.shared.user.clubsObjects().first(where: { $0.code == codeClub })
@ -23,14 +40,6 @@ struct FederalTournament: Identifiable, Codable, Hashable {
Logger.error(error) Logger.error(error)
} }
} }
if let club, club.creator == nil {
club.creator = StoreCenter.main.userId
do {
try DataStore.shared.clubs.addOrUpdate(instance: club)
} catch {
Logger.error(error)
}
}
return event! return event!
} }
@ -41,7 +50,7 @@ struct FederalTournament: Identifiable, Codable, Hashable {
} }
let id: String let id: Int
var millesime: Int? var millesime: Int?
var libelle: String? var libelle: String?
var tmc: Bool? var tmc: Bool?
@ -81,106 +90,8 @@ struct FederalTournament: Identifiable, Codable, Hashable {
var dateFin, dateValidation: Date? var dateFin, dateValidation: Date?
var codePostalEngagement, codeClub: String? var codePostalEngagement, codeClub: String?
var prixEspece: Int? var prixEspece: Int?
var japPhoneNumber: String? var distanceEnMetres: Double?
mutating func updateJapPhoneNumber(phone: String?) {
self.japPhoneNumber = phone
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Handle id that could be string or int
if let idString = try? container.decode(String.self, forKey: .id) {
id = idString
} else if let idInt = try? container.decode(Int.self, forKey: .id) {
id = String(idInt)
} else {
throw DecodingError.dataCorruptedError(forKey: .id, in: container,
debugDescription: "Expected String or Int for id")
}
// Regular decoding for simple properties
millesime = try container.decodeIfPresent(Int.self, forKey: .millesime)
libelle = try container.decodeIfPresent(String.self, forKey: .libelle)
tmc = try container.decodeIfPresent(Bool.self, forKey: .tmc)
tarifAdulteChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifAdulteChampionnat)
type = try container.decodeIfPresent(String.self, forKey: .type)
ageReel = try container.decodeIfPresent(Bool.self, forKey: .ageReel)
naturesTerrains = try container.decodeIfPresent([JSONAny].self, forKey: .naturesTerrains)
idsArbitres = try container.decodeIfPresent([JSONAny].self, forKey: .idsArbitres)
tarifJeuneChampionnat = try container.decodeIfPresent(Double.self, forKey: .tarifJeuneChampionnat)
international = try container.decodeIfPresent(Bool.self, forKey: .international)
inscriptionEnLigne = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigne)
categorieTournoi = try container.decodeIfPresent(CategorieTournoi.self, forKey: .categorieTournoi)
prixLot = try container.decodeIfPresent(Int.self, forKey: .prixLot)
paiementEnLigne = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigne)
reductionAdherentJeune = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentJeune)
reductionAdherentAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionAdherentAdulte)
paiementEnLigneObligatoire = try container.decodeIfPresent(Bool.self, forKey: .paiementEnLigneObligatoire)
villeEngagement = try container.decodeIfPresent(String.self, forKey: .villeEngagement)
senior = try container.decodeIfPresent(Bool.self, forKey: .senior)
veteran = try container.decodeIfPresent(Bool.self, forKey: .veteran)
inscriptionEnLigneEnCours = try container.decodeIfPresent(Bool.self, forKey: .inscriptionEnLigneEnCours)
avecResultatPublie = try container.decodeIfPresent(Bool.self, forKey: .avecResultatPublie)
code = try container.decodeIfPresent(String.self, forKey: .code)
categorieAge = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAge)
codeComite = try container.decodeIfPresent(String.self, forKey: .codeComite)
installations = try container.decodeIfPresent([JSONAny].self, forKey: .installations)
reductionEpreuveSupplementaireJeune = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireJeune)
reductionEpreuveSupplementaireAdulte = try container.decodeIfPresent(Double.self, forKey: .reductionEpreuveSupplementaireAdulte)
nomComite = try container.decodeIfPresent(String.self, forKey: .nomComite)
naturesEpreuves = try container.decodeIfPresent([Serie].self, forKey: .naturesEpreuves)
jeune = try container.decodeIfPresent(Bool.self, forKey: .jeune)
courrielEngagement = try container.decodeIfPresent(String.self, forKey: .courrielEngagement)
nomClub = try container.decodeIfPresent(String.self, forKey: .nomClub)
installation = try container.decodeIfPresent(Installation.self, forKey: .installation)
categorieAgeMax = try container.decodeIfPresent(CategorieAge.self, forKey: .categorieAgeMax)
tournoiInterne = try container.decodeIfPresent(Bool.self, forKey: .tournoiInterne)
nomLigue = try container.decodeIfPresent(String.self, forKey: .nomLigue)
nomEngagement = try container.decodeIfPresent(String.self, forKey: .nomEngagement)
codeLigue = try container.decodeIfPresent(String.self, forKey: .codeLigue)
modeleDeBalle = try container.decodeIfPresent(ModeleDeBalle.self, forKey: .modeleDeBalle)
jugeArbitre = try container.decodeIfPresent(JugeArbitre.self, forKey: .jugeArbitre)
adresse2Engagement = try container.decodeIfPresent(String.self, forKey: .adresse2Engagement)
epreuves = try container.decodeIfPresent([Epreuve].self, forKey: .epreuves)
serie = try container.decodeIfPresent(Serie.self, forKey: .serie)
codePostalEngagement = try container.decodeIfPresent(String.self, forKey: .codePostalEngagement)
codeClub = try container.decodeIfPresent(String.self, forKey: .codeClub)
prixEspece = try container.decodeIfPresent(Int.self, forKey: .prixEspece)
// Custom decoding for dateDebut
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateDebut) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateDebut = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateFin
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateFin) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateFin = dateFormatter.date(from: dateString)
}
}
// Custom decoding for dateValidation
if let dateContainer = try? container.nestedContainer(keyedBy: DateKeys.self, forKey: .dateValidation) {
if let dateString = try? dateContainer.decode(String.self, forKey: .date) {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
dateValidation = dateFormatter.date(from: dateString)
}
}
}
private enum DateKeys: String, CodingKey {
case date
}
var dayPeriod: DayPeriod { var dayPeriod: DayPeriod {
if let dateDebut { if let dateDebut {
let day = dateDebut.get(.weekday) let day = dateDebut.get(.weekday)
@ -254,11 +165,11 @@ struct FederalTournament: Identifiable, Codable, Hashable {
} }
var japMessage: String { var japMessage: String {
[nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, japPhoneNumber].compactMap({$0}).joined(separator: ";") [nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: ";")
} }
func umpireLabel() -> String { func umpireLabel() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).map({ $0.lowercased().capitalized }).joined(separator: " ") [jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).joined(separator: " ")
} }
func phoneLabel() -> String { func phoneLabel() -> String {
@ -313,7 +224,7 @@ extension FederalTournament: FederalTournamentHolder {
} }
// MARK: - CategorieAge // MARK: - CategorieAge
struct CategorieAge: Codable, Hashable { struct CategorieAge: Codable {
var ageJoueurMin, ageMin, ageJoueurMax, ageRechercheMax: Int? var ageJoueurMin, ageMin, ageJoueurMax, ageRechercheMax: Int?
var categoriesAgeTypePratique: [CategoriesAgeTypePratique]? var categoriesAgeTypePratique: [CategoriesAgeTypePratique]?
var ageMax: Int? var ageMax: Int?
@ -325,28 +236,35 @@ struct CategorieAge: Codable, Hashable {
var tournamentAge: FederalTournamentAge? { var tournamentAge: FederalTournamentAge? {
if let id { if let id {
return FederalTournamentAge(rawValue: id) ?? .senior return FederalTournamentAge(rawValue: id)
} }
if let libelle { if let libelle {
return FederalTournamentAge.allCases.first(where: { $0.localizedFederalAgeLabel().localizedCaseInsensitiveContains(libelle) }) ?? .senior return FederalTournamentAge.allCases.first(where: { $0.localizedLabel().localizedCaseInsensitiveContains(libelle) })
} }
return .senior return nil
} }
} }
// MARK: - CategoriesAgeTypePratique // MARK: - CategoriesAgeTypePratique
struct CategoriesAgeTypePratique: Codable, Hashable { struct CategoriesAgeTypePratique: Codable {
var id: ID? var id: ID?
} }
// MARK: - ID // MARK: - ID
struct ID: Codable, Hashable { struct ID: Codable {
var typePratique: String? var typePratique: TypePratique?
var idCategorieAge: Int? var idCategorieAge: Int?
} }
enum TypePratique: String, Codable {
case beach = "BEACH"
case padel = "PADEL"
case tennis = "TENNIS"
case pickle = "PICKLE"
}
// MARK: - CategorieTournoi // MARK: - CategorieTournoi
struct CategorieTournoi: Codable, Hashable { struct CategorieTournoi: Codable {
var code, codeTaxe: String? var code, codeTaxe: String?
var compteurGda: CompteurGda? var compteurGda: CompteurGda?
var libelle, niveauHierarchique: String? var libelle, niveauHierarchique: String?
@ -354,14 +272,14 @@ struct CategorieTournoi: Codable, Hashable {
} }
// MARK: - CompteurGda // MARK: - CompteurGda
struct CompteurGda: Codable, Hashable { struct CompteurGda: Codable {
var classementMax: Classement? var classementMax: Classement?
var libelle: String? var libelle: String?
var classementMin: Classement? var classementMin: Classement?
} }
// MARK: - Classement // MARK: - Classement
struct Classement: Codable, Hashable { struct Classement: Codable {
var nature, libelle: String? var nature, libelle: String?
var serie: Serie? var serie: Serie?
var sexe: String? var sexe: String?
@ -371,18 +289,18 @@ struct Classement: Codable, Hashable {
} }
// MARK: - Serie // MARK: - Serie
struct Serie: Codable, Hashable { struct Serie: Codable {
var code, libelle: String? var code, libelle: String?
var valide: Bool? var valide: Bool?
var sexe: String? var sexe: String?
var tournamentCategory: TournamentCategory? { var tournamentCategory: TournamentCategory? {
TournamentCategory.allCases.first(where: { $0.requestLabel == code }) ?? .men TournamentCategory.allCases.first(where: { $0.requestLabel == code })
} }
} }
// MARK: - Epreuve // MARK: - Epreuve
struct Epreuve: Codable, Hashable { struct Epreuve: Codable {
var inscriptionEnLigneEnCours: Bool? var inscriptionEnLigneEnCours: Bool?
var categorieAge: CategorieAge? var categorieAge: CategorieAge?
var typeEpreuve: TypeEpreuve? var typeEpreuve: TypeEpreuve?
@ -419,7 +337,7 @@ struct Epreuve: Codable, Hashable {
} }
// MARK: - TypeEpreuve // MARK: - TypeEpreuve
struct TypeEpreuve: Codable, Hashable { struct TypeEpreuve: Codable {
let code: String? let code: String?
let delai: Int? let delai: Int?
let libelle: String? let libelle: String?
@ -430,19 +348,19 @@ struct TypeEpreuve: Codable, Hashable {
var tournamentLevel: TournamentLevel? { var tournamentLevel: TournamentLevel? {
if let code, let value = Int(code.removingFirstCharacter) { if let code, let value = Int(code.removingFirstCharacter) {
return TournamentLevel(rawValue: value) ?? .p100 return TournamentLevel(rawValue: value)
} }
return .p100 return nil
} }
} }
// MARK: - BorneAnneesNaissance // MARK: - BorneAnneesNaissance
struct BorneAnneesNaissance: Codable, Hashable { struct BorneAnneesNaissance: Codable {
var min, max: Int? var min, max: Int?
} }
// MARK: - Installation // MARK: - Installation
struct Installation: Codable, Hashable { struct Installation: Codable {
var ville: String? var ville: String?
var lng: Double? var lng: Double?
var surfaces: [JSONAny]? var surfaces: [JSONAny]?
@ -457,7 +375,7 @@ struct Installation: Codable, Hashable {
} }
// MARK: - JugeArbitre // MARK: - JugeArbitre
struct JugeArbitre: Codable, Hashable { struct JugeArbitre: Codable {
var idCRM, id: Int? var idCRM, id: Int?
var nom, prenom: String? var nom, prenom: String?
@ -468,7 +386,7 @@ struct JugeArbitre: Codable, Hashable {
} }
// MARK: - ModeleDeBalle // MARK: - ModeleDeBalle
struct ModeleDeBalle: Codable, Hashable { struct ModeleDeBalle: Codable {
var libelle: String? var libelle: String?
var marqueDeBalle: MarqueDeBalle? var marqueDeBalle: MarqueDeBalle?
var id: Int? var id: Int?
@ -476,7 +394,7 @@ struct ModeleDeBalle: Codable, Hashable {
} }
// MARK: - MarqueDeBalle // MARK: - MarqueDeBalle
struct MarqueDeBalle: Codable, Hashable { struct MarqueDeBalle: Codable {
var id: Int? var id: Int?
var valide: Bool? var valide: Bool?
var marque: String? var marque: String?
@ -529,13 +447,9 @@ class JSONCodingKey: CodingKey {
} }
} }
class JSONAny: Codable, Hashable, Equatable { class JSONAny: Codable {
var value: Any let value: Any
init() {
self.value = ()
}
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError { static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny") let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
@ -726,70 +640,4 @@ class JSONAny: Codable, Hashable, Equatable {
try JSONAny.encode(to: &container, value: self.value) try JSONAny.encode(to: &container, value: self.value)
} }
} }
public static func == (lhs: JSONAny, rhs: JSONAny) -> Bool {
switch (lhs.value, rhs.value) {
case (let l as Bool, let r as Bool): return l == r
case (let l as Int64, let r as Int64): return l == r
case (let l as Double, let r as Double): return l == r
case (let l as String, let r as String): return l == r
case (let l as JSONNull, let r as JSONNull): return true
case (let l as [Any], let r as [Any]):
guard l.count == r.count else { return false }
return zip(l, r).allSatisfy { (a, b) in
// Recursively wrap in JSONAny for comparison
JSONAny(value: a) == JSONAny(value: b)
}
case (let l as [String: Any], let r as [String: Any]):
guard l.count == r.count else { return false }
for (key, lVal) in l {
guard let rVal = r[key], JSONAny(value: lVal) == JSONAny(value: rVal) else { return false }
}
return true
default:
return false
}
}
public func hash(into hasher: inout Hasher) {
switch value {
case let v as Bool:
hasher.combine(0)
hasher.combine(v)
case let v as Int64:
hasher.combine(1)
hasher.combine(v)
case let v as Double:
hasher.combine(2)
hasher.combine(v)
case let v as String:
hasher.combine(3)
hasher.combine(v)
case is JSONNull:
hasher.combine(4)
case let v as [Any]:
hasher.combine(5)
for elem in v {
JSONAny(value: elem).hash(into: &hasher)
}
case let v as [String: Any]:
hasher.combine(6)
// Order of hashing dictionary keys shouldn't matter
for key in v.keys.sorted() {
hasher.combine(key)
if let val = v[key] {
JSONAny(value: val).hash(into: &hasher)
}
}
default:
hasher.combine(-1)
}
}
// Helper init for internal use
convenience init(value: Any) {
self.init()
self.value = value
}
} }

@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import PadelClubData
protocol FederalTournamentHolder { protocol FederalTournamentHolder {
var holderId: String { get } var holderId: String { get }
@ -23,6 +22,7 @@ protocol FederalTournamentHolder {
} }
extension FederalTournamentHolder { extension FederalTournamentHolder {
func durationLabel() -> String { func durationLabel() -> String {
switch dayDuration { switch dayDuration {
case 1: case 1:

@ -0,0 +1,708 @@
//
// GroupStage.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
import Algorithms
import SwiftUI
@Observable
final class GroupStage: ModelObject, Storable {
static func resourceName() -> String { "group-stages" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var tournament: String
var index: Int
var size: Int
private var format: MatchFormat?
var startDate: Date?
var name: String?
var step: Int = 0
var matchFormat: MatchFormat {
get {
format ?? .defaultFormatForMatchType(.groupStage)
}
set {
format = newValue
}
}
internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil, step: Int = 0) {
self.tournament = tournament
self.index = index
self.size = size
self.format = matchFormat
self.startDate = startDate
self.name = name
self.step = step
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
// MARK: - Computed dependencies
func _matches() -> [Match] {
return self.tournamentStore.matches.filter { $0.groupStage == self.id }.sorted(by: \.index)
// Store.main.filter { $0.groupStage == self.id }
}
func tournamentObject() -> Tournament? {
Store.main.findById(self.tournament)
}
// MARK: -
func teamAt(groupStagePosition: Int) -> TeamRegistration? {
if step > 0 {
return teams().first(where: { $0.groupStagePositionAtStep(step) == groupStagePosition })
}
return teams().first(where: { $0.groupStagePosition == groupStagePosition })
}
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let name { return name }
var stepLabel = ""
if step > 0 {
stepLabel = " (" + (step + 1).ordinalFormatted(feminine: true) + " phase)"
}
switch displayStyle {
case .title:
return "Poule \(index + 1)" + stepLabel
case .wide:
return "Poule \(index + 1)"
case .short:
return "#\(index + 1)"
}
}
var computedOrder: Int {
index + step * 100
}
func isRunning() -> Bool { // at least a match has started
_matches().anySatisfy({ $0.isRunning() })
}
func hasStarted() -> Bool { // meaning at least one match is over
_matches().filter { $0.hasEnded() }.isEmpty == false
}
func hasEnded() -> Bool {
let _matches = _matches()
if _matches.isEmpty { return false }
//guard teams().count == size else { return false }
return _matches.anySatisfy { $0.hasEnded() == false } == false
}
fileprivate func _createMatch(index: Int) -> Match {
let match: Match = Match(groupStage: self.id,
index: index,
matchFormat: self.matchFormat,
name: self.localizedMatchUpLabel(for: index))
match.store = self.store
print("_createMatch(index)", index)
return match
}
func removeReturnMatches(onlyLast: Bool = false) {
var returnMatches = _matches().filter({ $0.index >= matchCount })
if onlyLast {
let matchPhaseCount = matchPhaseCount - 1
returnMatches = returnMatches.filter({ $0.index >= matchCount * matchPhaseCount })
}
do {
try self.tournamentStore.matches.delete(contentOfs: returnMatches)
} catch {
Logger.error(error)
}
}
var matchPhaseCount: Int {
let count = _matches().count
if matchCount > 0 {
return count / matchCount
} else {
return 0
}
}
func addReturnMatches() {
var teamScores = [TeamScore]()
var matches = [Match]()
let matchPhaseCount = matchPhaseCount
for i in 0..<_numberOfMatchesToBuild() {
let newMatch = self._createMatch(index: i + matchCount * matchPhaseCount)
// let newMatch = Match(groupStage: self.id, index: i, matchFormat: self.matchFormat, name: localizedMatchUpLabel(for: i))
teamScores.append(contentsOf: newMatch.createTeamScores())
matches.append(newMatch)
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores)
} catch {
Logger.error(error)
}
}
func buildMatches(keepExistingMatches: Bool = false) {
var teamScores = [TeamScore]()
var matches = [Match]()
clearScoreCache()
if keepExistingMatches == false {
_removeMatches()
for i in 0..<_numberOfMatchesToBuild() {
let newMatch = self._createMatch(index: i)
// let newMatch = Match(groupStage: self.id, index: i, matchFormat: self.matchFormat, name: localizedMatchUpLabel(for: i))
teamScores.append(contentsOf: newMatch.createTeamScores())
matches.append(newMatch)
}
} else {
for match in _matches() {
match.resetTeamScores(outsideOf: [])
teamScores.append(contentsOf: match.createTeamScores())
}
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores)
} catch {
Logger.error(error)
}
}
func playedMatches() -> [Match] {
let ordered = _matches()
let order = _matchOrder()
let matchCount = max(1, matchCount)
let count = ordered.count / matchCount
if ordered.isEmpty == false && ordered.count % order.count == 0 {
let repeatedArray = (0..<count).flatMap { i in
order.map { $0 + i * order.count }
}
let result = repeatedArray.map { ordered[$0] }
return result
} else {
return ordered
}
}
func orderedIndexOfMatch(_ match: Match) -> Int {
_matchOrder()[safe: match.index] ?? match.index
}
func updateGroupStageState() {
clearScoreCache()
if hasEnded(), let tournament = tournamentObject() {
do {
let teams = teams(true)
for (index, team) in teams.enumerated() {
team.qualified = index < tournament.qualifiedPerGroupStage
if team.bracketPosition != nil && team.qualified == false {
tournamentObject()?.shouldVerifyBracket = true
}
}
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
if let tournamentObject = tournamentObject() {
try DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject)
}
} catch {
Logger.error(error)
}
let groupStagesAreOverAtFirstStep = tournament.groupStagesAreOver(atStep: 0)
let nextStepGroupStages = tournament.groupStages(atStep: 1)
let groupStagesAreOverAtSecondStep = tournament.groupStagesAreOver(atStep: 1)
if groupStagesAreOverAtFirstStep, nextStepGroupStages.isEmpty || groupStagesAreOverAtSecondStep == true, tournament.groupStageLoserBracketAreOver(), tournament.rounds().isEmpty {
tournament.endDate = Date()
do {
try DataStore.shared.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
}
}
func scoreLabel(forGroupStagePosition groupStagePosition: Int, score: TeamGroupStageScore? = nil) -> (wins: String, losses: String, setsDifference: String?, gamesDifference: String?)? {
if let scoreData = (score ?? _score(forGroupStagePosition: groupStagePosition, nilIfEmpty: true)) {
let hideSetDifference = matchFormat.setsToWin == 1
let setDifference = scoreData.setDifference.formatted(.number.sign(strategy: .always(includingZero: true))) + " set" + scoreData.setDifference.pluralSuffix
let gameDifference = scoreData.gameDifference.formatted(.number.sign(strategy: .always(includingZero: true))) + " jeu" + scoreData.gameDifference.localizedPluralSuffix("x")
return (wins: scoreData.wins.formatted(), losses: scoreData.loses.formatted(), setsDifference: hideSetDifference ? nil : setDifference, gamesDifference: gameDifference)
// return "\(scoreData.wins)/\(scoreData.loses) " + differenceAsString
} else {
return nil
}
}
// func _score(forGroupStagePosition groupStagePosition: Int, nilIfEmpty: Bool = false) -> TeamGroupStageScore? {
// guard let team = teamAt(groupStagePosition: groupStagePosition) else { return nil }
// let matches = matches(forGroupStagePosition: groupStagePosition).filter({ $0.hasEnded() })
// if matches.isEmpty && nilIfEmpty { return nil }
// let wins = matches.filter { $0.winningTeamId == team.id }.count
// let loses = matches.filter { $0.losingTeamId == team.id }.count
// let differences = matches.compactMap { $0.scoreDifference(groupStagePosition, atStep: step) }
// let setDifference = differences.map { $0.set }.reduce(0,+)
// let gameDifference = differences.map { $0.game }.reduce(0,+)
// return (team, wins, loses, setDifference, gameDifference)
// /*
// 2 points par rencontre gagnée
// 1 point par rencontre perdue
// -1 point en cas de rencontre perdue par disqualification (scores de 6/0 6/0 attribués aux trois matchs)
// -2 points en cas de rencontre perdu par WO (scores de 6/0 6/0 attribués aux trois matchs)
// */
// }
//
func matches(forGroupStagePosition groupStagePosition: Int) -> [Match] {
let combos = Array((0..<size).combinations(ofCount: 2))
var matchIndexes = [Int]()
for (index, combo) in combos.enumerated() {
if combo.contains(groupStagePosition) { //team is playing
matchIndexes.append(index)
}
}
return _matches().filter { matchIndexes.contains($0.index%matchCount) }
}
func initialStartDate(forTeam team: TeamRegistration) -> Date? {
guard let groupStagePosition = team.groupStagePositionAtStep(step) else { return nil }
return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate
}
func matchPlayed(by groupStagePosition: Int, againstPosition: Int) -> Match? {
if groupStagePosition == againstPosition { return nil }
let combos = Array((0..<size).combinations(ofCount: 2))
var matchIndexes = [Int]()
for (index, combo) in combos.enumerated() {
if combo.contains(groupStagePosition) && combo.contains(againstPosition) { //teams are playing
matchIndexes.append(index)
}
}
return _matches().first(where: { matchIndexes.contains($0.index) })
}
func availableToStart(playedMatches: [Match], in runningMatches: [Match], checkCanPlay: Bool = true) -> [Match] {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func group stage availableToStart", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return playedMatches.filter({ $0.isRunning() == false && $0.canBeStarted(inMatches: runningMatches, checkCanPlay: checkCanPlay) }).sorted(by: \.computedStartDateForSorting)
}
func runningMatches(playedMatches: [Match]) -> [Match] {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func group stage runningMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return playedMatches.filter({ $0.isRunning() }).sorted(by: \.computedStartDateForSorting)
}
func readyMatches(playedMatches: [Match]) -> [Match] {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func group stage readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return playedMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false })
}
func finishedMatches(playedMatches: [Match]) -> [Match] {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func group stage finishedMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return playedMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed()
}
func isReturnMatchEnabled() -> Bool {
_matches().count > matchCount
}
private func _matchOrder() -> [Int] {
var order: [Int]
switch size {
case 3:
order = [1, 2, 0]
case 4:
order = [2, 3, 1, 4, 5, 0]
case 5:
order = [3, 5, 8, 2, 6, 1, 9, 4, 7, 0]
case 6:
order = [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0]
default:
order = []
}
return order
}
func indexOf(_ matchIndex: Int) -> Int {
_matchOrder().firstIndex(of: matchIndex) ?? matchIndex
}
func _matchUp(for matchIndex: Int) -> [Int] {
let combinations = Array((0..<size).combinations(ofCount: 2))
return combinations[safe: matchIndex%matchCount] ?? []
}
func returnMatchesSuffix(for matchIndex: Int) -> String {
if matchCount > 0 {
let count = _matches().count
if count > matchCount * 2 {
return " - vague \((matchIndex / matchCount) + 1)"
}
if matchIndex >= matchCount {
return " - retour"
}
}
return ""
}
func localizedMatchUpLabel(for matchIndex: Int) -> String {
let matchUp = _matchUp(for: matchIndex)
if let index = matchUp.first, let index2 = matchUp.last {
return "#\(index + 1) vs #\(index2 + 1)" + returnMatchesSuffix(for: matchIndex)
} else {
return "--"
}
}
var matchCount: Int {
(size * (size - 1)) / 2
}
func team(teamPosition team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? {
let _teams = _teams(for: matchIndex)
switch team {
case .one:
return _teams.first ?? nil
case .two:
return _teams.last ?? nil
}
}
private func _teams(for matchIndex: Int) -> [TeamRegistration?] {
let combinations = Array(0..<size).combinations(ofCount: 2).map {$0}
return combinations[safe: matchIndex%matchCount]?.map { teamAt(groupStagePosition: $0) } ?? []
}
private func _removeMatches() {
do {
try self.tournamentStore.matches.delete(contentOfs: _matches())
} catch {
Logger.error(error)
}
}
private func _numberOfMatchesToBuild() -> Int {
(size * (size - 1)) / 2
}
func unsortedPlayers() -> [PlayerRegistration] {
unsortedTeams().flatMap({ $0.unsortedPlayers() })
}
fileprivate typealias TeamScoreAreInIncreasingOrder = (TeamGroupStageScore, TeamGroupStageScore) -> Bool
typealias TeamGroupStageScore = (team: TeamRegistration, wins: Int, loses: Int, setDifference: Int, gameDifference: Int)
fileprivate func _headToHead(_ teamPosition: TeamRegistration, _ otherTeam: TeamRegistration) -> Bool {
let indexes = [teamPosition, otherTeam].compactMap({ $0.groupStagePosition }).sorted()
let combos = Array((0..<size).combinations(ofCount: 2))
let matchIndexes = combos.enumerated().compactMap { $0.element == indexes ? $0.offset : nil }
let matches = _matches().filter { matchIndexes.contains($0.index) }
if matches.count > 1 {
let scoreA = calculateScore(for: teamPosition, matches: matches, groupStagePosition: teamPosition.groupStagePosition!)
let scoreB = calculateScore(for: otherTeam, matches: matches, groupStagePosition: otherTeam.groupStagePosition!)
let teamsSorted = [scoreA, scoreB].sorted { (lhs, rhs) in
let predicates: [TeamScoreAreInIncreasingOrder] = [
{ $0.wins < $1.wins },
{ $0.setDifference < $1.setDifference },
{ $0.gameDifference < $1.gameDifference},
{ [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! }
]
for predicate in predicates {
if !predicate(lhs, rhs) && !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
}.map({ $0.team })
return teamsSorted.first == teamPosition
} else {
if let matchIndex = combos.firstIndex(of: indexes), let match = _matches().first(where: { $0.index == matchIndex }) {
return teamPosition.id == match.losingTeamId
} else {
return false
}
}
}
func unsortedTeams() -> [TeamRegistration] {
if step > 0 {
return self.tournamentStore.groupStages.filter({ $0.step == step - 1 }).compactMap({ $0.teams(true)[safe: index] })
}
return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil }
}
var scoreCache: [Int: TeamGroupStageScore] = [:]
func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] {
if sortedByScore {
return unsortedTeams().compactMap({ team in
// Check cache or use provided scores, otherwise calculate and store in cache
scores?.first(where: { $0.team.id == team.id }) ?? {
if let cachedScore = scoreCache[team.groupStagePositionAtStep(step)!] {
return cachedScore
} else {
let score = _score(forGroupStagePosition: team.groupStagePositionAtStep(step)!)
if let score = score {
scoreCache[team.groupStagePositionAtStep(step)!] = score
}
return score
}
}()
}).sorted { (lhs, rhs) in
let predicates: [TeamScoreAreInIncreasingOrder] = [
{ $0.wins < $1.wins },
{ $0.setDifference < $1.setDifference },
{ $0.gameDifference < $1.gameDifference},
{ self._headToHead($0.team, $1.team) },
{ [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! }
]
for predicate in predicates {
if !predicate(lhs, rhs) && !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
}.map({ $0.team }).reversed()
} else {
return unsortedTeams().sorted(by: \TeamRegistration.groupStagePosition!)
}
}
func _score(forGroupStagePosition groupStagePosition: Int, nilIfEmpty: Bool = false) -> TeamGroupStageScore? {
// Check if the score for this position is already cached
if let cachedScore = scoreCache[groupStagePosition] {
return cachedScore
}
guard let team = teamAt(groupStagePosition: groupStagePosition) else { return nil }
let matches = matches(forGroupStagePosition: groupStagePosition).filter({ $0.hasEnded() })
if matches.isEmpty && nilIfEmpty { return nil }
let score = calculateScore(for: team, matches: matches, groupStagePosition: groupStagePosition)
scoreCache[groupStagePosition] = score
return score
}
private func calculateScore(for team: TeamRegistration, matches: [Match], groupStagePosition: Int) -> TeamGroupStageScore {
let wins = matches.filter { $0.winningTeamId == team.id }.count
let loses = matches.filter { $0.losingTeamId == team.id }.count
let differences = matches.compactMap { $0.scoreDifference(groupStagePosition, atStep: step) }
let setDifference = differences.map { $0.set }.reduce(0,+)
let gameDifference = differences.map { $0.game }.reduce(0,+)
return (team, wins, loses, setDifference, gameDifference)
}
// Clear the cache if necessary, for example when starting a new step or when matches update
func clearScoreCache() {
scoreCache.removeAll()
}
// func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] {
// if sortedByScore {
// return unsortedTeams().compactMap({ team in
// scores?.first(where: { $0.team.id == team.id }) ?? _score(forGroupStagePosition: team.groupStagePositionAtStep(step)!)
// }).sorted { (lhs, rhs) in
// // Calculate intermediate values once and reuse them
// let lhsWins = lhs.wins
// let rhsWins = rhs.wins
// let lhsSetDifference = lhs.setDifference
// let rhsSetDifference = rhs.setDifference
// let lhsGameDifference = lhs.gameDifference
// let rhsGameDifference = rhs.gameDifference
// let lhsHeadToHead = self._headToHead(lhs.team, rhs.team)
// let rhsHeadToHead = self._headToHead(rhs.team, lhs.team)
// let lhsGroupStagePosition = lhs.team.groupStagePositionAtStep(self.step)!
// let rhsGroupStagePosition = rhs.team.groupStagePositionAtStep(self.step)!
//
// // Define comparison predicates in the same order
// let predicates: [(Bool, Bool)] = [
// (lhsWins < rhsWins, lhsWins > rhsWins),
// (lhsSetDifference < rhsSetDifference, lhsSetDifference > rhsSetDifference),
// (lhsGameDifference < rhsGameDifference, lhsGameDifference > rhsGameDifference),
// (lhsHeadToHead, rhsHeadToHead),
// (lhsGroupStagePosition > rhsGroupStagePosition, lhsGroupStagePosition < rhsGroupStagePosition)
// ]
//
// // Iterate over predicates and return as soon as a valid comparison is found
// for (lhsPredicate, rhsPredicate) in predicates {
// if lhsPredicate { return true }
// if rhsPredicate { return false }
// }
//
// return false
// }.map({ $0.team }).reversed()
// } else {
// return unsortedTeams().sorted(by: \TeamRegistration.groupStagePosition!)
// }
// }
func updateMatchFormat(_ updatedMatchFormat: MatchFormat) {
self.matchFormat = updatedMatchFormat
self.updateAllMatchesFormat()
}
func updateAllMatchesFormat() {
let playedMatches = playedMatches()
playedMatches.forEach { match in
match.matchFormat = matchFormat
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: playedMatches)
} catch {
Logger.error(error)
}
}
func pasteData() -> String {
var data: [String] = []
data.append(self.groupStageTitle())
teams().forEach { team in
data.append(team.teamLabelRanked(displayRank: true, displayTeamName: true))
}
return data.joined(separator: "\n")
}
func finalPosition(ofTeam team: TeamRegistration) -> Int? {
guard hasEnded() else { return nil }
return teams(true).firstIndex(of: team)
}
override func deleteDependencies() throws {
let matches = self._matches()
for match in matches {
try match.deleteDependencies()
}
self.tournamentStore.matches.deleteDependencies(matches)
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: ._id)
tournament = try container.decode(String.self, forKey: ._tournament)
index = try container.decode(Int.self, forKey: ._index)
size = try container.decode(Int.self, forKey: ._size)
format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format)
startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate)
name = try container.decodeIfPresent(String.self, forKey: ._name)
step = try container.decodeIfPresent(Int.self, forKey: ._step) ?? 0
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(tournament, forKey: ._tournament)
try container.encode(index, forKey: ._index)
try container.encode(size, forKey: ._size)
try container.encode(format, forKey: ._format)
try container.encode(startDate, forKey: ._startDate)
try container.encode(name, forKey: ._name)
try container.encode(step, forKey: ._step)
}
func insertOnServer() {
self.tournamentStore.groupStages.writeChangeAndInsertOnServer(instance: self)
for match in self._matches() {
match.insertOnServer()
}
}
}
extension GroupStage {
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"
case _index = "index"
case _size = "size"
case _format = "format"
case _startDate = "startDate"
case _name = "name"
case _step = "step"
}
}
extension GroupStage: Selectable {
func selectionLabel(index: Int) -> String {
groupStageTitle()
}
func badgeValue() -> Int? {
return runningMatches(playedMatches: _matches()).count
}
func badgeValueColor() -> Color? {
return nil
}
func badgeImage() -> Badge? {
if teams().count < size {
return .xmark
} else {
return hasEnded() ? .checkmark : nil
}
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,918 @@
//
// MatchScheduler.swift
// PadelClub
//
// Created by Razmig Sarkissian on 08/04/2024.
//
import Foundation
import LeStorage
import SwiftUI
@Observable
final class MatchScheduler : ModelObject, Storable {
static func resourceName() -> String { return "match-scheduler" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
private(set) var id: String = Store.randomId()
var tournament: String
var timeDifferenceLimit: Int
var loserBracketRotationDifference: Int
var upperBracketRotationDifference: Int
var accountUpperBracketBreakTime: Bool
var accountLoserBracketBreakTime: Bool
var randomizeCourts: Bool
var rotationDifferenceIsImportant: Bool
var shouldHandleUpperRoundSlice: Bool
var shouldEndRoundBeforeStartingNext: Bool
var groupStageChunkCount: Int?
var overrideCourtsUnavailability: Bool = false
var shouldTryToFillUpCourtsAvailable: Bool = true
var courtsAvailable: Set<Int> = Set<Int>()
var simultaneousStart: Bool = true
init(tournament: String,
timeDifferenceLimit: Int = 5,
loserBracketRotationDifference: Int = 0,
upperBracketRotationDifference: Int = 1,
accountUpperBracketBreakTime: Bool = true,
accountLoserBracketBreakTime: Bool = false,
randomizeCourts: Bool = true,
rotationDifferenceIsImportant: Bool = false,
shouldHandleUpperRoundSlice: Bool = false,
shouldEndRoundBeforeStartingNext: Bool = true,
groupStageChunkCount: Int? = nil,
overrideCourtsUnavailability: Bool = false,
shouldTryToFillUpCourtsAvailable: Bool = true,
courtsAvailable: Set<Int> = Set<Int>(),
simultaneousStart: Bool = true) {
self.tournament = tournament
self.timeDifferenceLimit = timeDifferenceLimit
self.loserBracketRotationDifference = loserBracketRotationDifference
self.upperBracketRotationDifference = upperBracketRotationDifference
self.accountUpperBracketBreakTime = accountUpperBracketBreakTime
self.accountLoserBracketBreakTime = accountLoserBracketBreakTime
self.randomizeCourts = randomizeCourts
self.rotationDifferenceIsImportant = rotationDifferenceIsImportant
self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice
self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext
self.groupStageChunkCount = groupStageChunkCount
self.overrideCourtsUnavailability = overrideCourtsUnavailability
self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable
self.courtsAvailable = courtsAvailable
self.simultaneousStart = simultaneousStart
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"
case _timeDifferenceLimit = "timeDifferenceLimit"
case _loserBracketRotationDifference = "loserBracketRotationDifference"
case _upperBracketRotationDifference = "upperBracketRotationDifference"
case _accountUpperBracketBreakTime = "accountUpperBracketBreakTime"
case _accountLoserBracketBreakTime = "accountLoserBracketBreakTime"
case _randomizeCourts = "randomizeCourts"
case _rotationDifferenceIsImportant = "rotationDifferenceIsImportant"
case _shouldHandleUpperRoundSlice = "shouldHandleUpperRoundSlice"
case _shouldEndRoundBeforeStartingNext = "shouldEndRoundBeforeStartingNext"
case _groupStageChunkCount = "groupStageChunkCount"
case _overrideCourtsUnavailability = "overrideCourtsUnavailability"
case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable"
case _courtsAvailable = "courtsAvailable"
case _simultaneousStart = "simultaneousStart"
}
var courtsUnavailability: [DateInterval]? {
guard let event = tournamentObject()?.eventObject() else { return nil }
return event.courtsUnavailability + (overrideCourtsUnavailability ? [] : event.tournamentsCourtsUsed(exluding: tournament))
}
var additionalEstimationDuration : Int {
return tournamentObject()?.additionalEstimationDuration ?? 0
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
func tournamentObject() -> Tournament? {
return Store.main.findById(tournament)
}
@discardableResult
func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date {
let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue()
var groupStages: [GroupStage] = tournament.groupStages(atStep: step)
if let specificGroupStage {
groupStages = [specificGroupStage]
}
let matches = groupStages.flatMap { $0._matches() }
matches.forEach({
$0.removeCourt()
$0.startDate = nil
$0.confirmed = false
})
var lastDate : Date = startDate ?? tournament.startDate
let times = Set(groupStages.compactMap { $0.startDate }).sorted()
if let first = times.first {
if first.isEarlierThan(tournament.startDate) {
tournament.startDate = first
do {
try DataStore.shared.tournaments.addOrUpdate(instance: tournament)
} catch {
Logger.error(error)
}
}
}
times.forEach({ time in
if lastDate.isEarlierThan(time) {
lastDate = time
}
let groups = groupStages.filter({ $0.startDate == time })
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
dispatch.timedMatches.forEach { matchSchedule in
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60
if let startDate = match.groupStageObject?.startDate {
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd)
match.startDate = matchStartDate
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60)
}
match.setCourt(matchSchedule.courtIndex)
}
}
})
groupStages.filter({ $0.startDate == nil || times.contains($0.startDate!) == false }).chunked(into: computedGroupStageChunkCount).forEach { groups in
groups.forEach({ $0.startDate = lastDate })
do {
try self.tournamentStore.groupStages.addOrUpdate(contentOfs: groups)
} catch {
Logger.error(error)
}
let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate)
dispatch.timedMatches.forEach { matchSchedule in
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) {
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60
if let startDate = match.groupStageObject?.startDate {
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd)
match.startDate = matchStartDate
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60)
}
match.setCourt(matchSchedule.courtIndex)
}
}
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
return lastDate
}
func groupStageDispatcher(groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher {
let _groupStages = groupStages
// Get the maximum count of matches in any group
let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0
var flattenedMatches = [Match]()
if simultaneousStart {
// Flatten matches in a round-robin order by cycling through each group
flattenedMatches = (0..<maxMatchesCount).flatMap { index in
_groupStages.compactMap { group in
// Safely access matches, return nil if index is out of bounds
let playedMatches = group.playedMatches()
return playedMatches.indices.contains(index) ? playedMatches[index] : nil
}
}
} else {
flattenedMatches = _groupStages.flatMap({ $0.playedMatches() })
}
var slots = [GroupStageTimeMatch]()
var availableMatches = flattenedMatches
var rotationIndex = 0
var teamsPerRotation = [Int: [String]]() // Tracks teams assigned to each rotation
var freeCourtPerRotation = [Int: [Int]]() // Tracks free courts per rotation
var groupLastRotation = [Int: Int]() // Tracks the last rotation each group was involved in
let courtsUnavailability = courtsUnavailability
while slots.count < flattenedMatches.count {
print("Starting rotation \(rotationIndex) with \(availableMatches.count) matches left")
teamsPerRotation[rotationIndex] = []
freeCourtPerRotation[rotationIndex] = []
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }
.map { ($0.groupIndex, 1) }
let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +)
var rotationMatches = Array(availableMatches.filter({ match in
// Check if all teams from the match are not already scheduled in the current rotation
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamIndex($0) })
if !teamsAvailable {
print("Match \(match.roundAndMatchTitle()) has teams already scheduled in rotation \(rotationIndex)")
}
return teamsAvailable
}))
if rotationIndex > 0 {
rotationMatches = rotationMatches.sorted(by: {
if counts[$0.groupStageObject!.index] ?? 0 == counts[$1.groupStageObject!.index] ?? 0 {
if simultaneousStart {
return $0.groupStageObject!.orderedIndexOfMatch($0) < $1.groupStageObject!.orderedIndexOfMatch($1)
} else {
return $0.groupStageObject!.index < $1.groupStageObject!.index
}
} else {
return counts[$0.groupStageObject!.index] ?? 0 < counts[$1.groupStageObject!.index] ?? 0
}
})
}
courtsAvailable.forEach { courtIndex in
print("Checking availability for court \(courtIndex) in rotation \(rotationIndex)")
if let first = rotationMatches.first(where: { match in
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let timeIntervalToAdd = Double(rotationIndex) * Double(estimatedDuration) * 60
let rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability)
if courtsUnavailable.contains(courtIndex) {
print("Court \(courtIndex) is unavailable at \(rotationStartDate)")
return false
}
let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamIndex($0) })
if !teamsAvailable {
print("Teams from match \(match.roundAndMatchTitle()) are already scheduled in this rotation")
return false
}
print("Match \(match.roundAndMatchTitle()) is available for court \(courtIndex) at \(rotationStartDate)")
return true
}) {
let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index)
print("Scheduled match: \(first.roundAndMatchTitle()) on court \(courtIndex) at rotation \(rotationIndex)")
slots.append(timeMatch)
teamsPerRotation[rotationIndex]!.append(contentsOf: first.matchUp())
rotationMatches.removeAll(where: { $0.id == first.id })
availableMatches.removeAll(where: { $0.id == first.id })
if let index = first.groupStageObject?.index {
groupLastRotation[index] = rotationIndex
}
} else {
print("No available matches for court \(courtIndex) in rotation \(rotationIndex), adding to free court list")
freeCourtPerRotation[rotationIndex]!.append(courtIndex)
}
}
rotationIndex += 1
}
print("All matches scheduled. Total rotations: \(rotationIndex)")
// Organize slots and ensure courts are randomized or sorted
var organizedSlots = [GroupStageTimeMatch]()
for i in 0..<rotationIndex {
let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted()
let courts: [Int] = randomizeCourts ? courtsSorted.shuffled() : courtsSorted
var matches = slots.filter({ $0.rotationIndex == i }).sorted(using: .keyPath(\.groupIndex), .keyPath(\.courtIndex))
for j in 0..<matches.count {
matches[j].courtIndex = courts[j]
organizedSlots.append(matches[j])
}
}
return GroupStageMatchDispatcher(
timedMatches: organizedSlots,
freeCourtPerRotation: freeCourtPerRotation,
rotationCount: rotationIndex,
groupLastRotation: groupLastRotation
)
}
func rotationDifference(loserBracket: Bool) -> Int {
if loserBracket {
return loserBracketRotationDifference
} else {
return upperBracketRotationDifference
}
}
func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool {
print("Evaluating match: \(match.roundAndMatchTitle()) in round: \(roundObject.roundTitle()) with index: \(match.index)")
if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate {
print("Cannot start at \(targetedStartDate), earlier than round start date \(roundStartDate)")
if targetedStartDate == minimumTargetedEndDate {
print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)")
minimumTargetedEndDate = roundStartDate
} else {
print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)")
minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate)
}
print("Returning false: Match cannot start earlier than the round start date.")
return false
}
let previousMatches = roundObject.precedentMatches(ofMatch: match)
if previousMatches.isEmpty {
print("No ancestors matches for this match, returning true. (eg beginning of tournament 1st bracket")
return true
}
let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) }
if previousMatchSlots.isEmpty {
if previousMatches.filter({ !$0.disabled }).allSatisfy({ $0.startDate != nil }) {
print("All previous matches have start dates, returning true.")
return true
}
print("Some previous matches are pending, returning false.")
return false
}
if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count {
if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) {
print("Some previous matches started, returning true.")
return true
}
print("Not enough previous matches have started, returning false.")
return false
}
var includeBreakTime = false
if accountLoserBracketBreakTime && roundObject.isLoserBracket() {
includeBreakTime = true
print("Including break time for loser bracket.")
}
if accountUpperBracketBreakTime && !roundObject.isLoserBracket() {
includeBreakTime = true
print("Including break time for upper bracket.")
}
let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy {
$0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex
}
if previousMatchIsInPreviousRotation {
print("All previous matches are from earlier rotations, returning true.")
} else {
print("Some previous matches are from the current rotation.")
}
guard let minimumPossibleEndDate = previousMatchSlots.map({
$0.estimatedEndDate(includeBreakTime: includeBreakTime)
}).max() else {
print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).")
return previousMatchIsInPreviousRotation
}
if targetedStartDate >= minimumPossibleEndDate {
if rotationDifferenceIsImportant {
print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).")
return previousMatchIsInPreviousRotation
} else {
print("Targeted start date is after the minimum possible end date, returning true.")
return true
}
} else {
if targetedStartDate == minimumTargetedEndDate {
print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)")
minimumTargetedEndDate = minimumPossibleEndDate
} else {
print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)")
minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate)
}
print("Targeted start date \(targetedStartDate) is before the minimum possible end date, returning false. \(minimumTargetedEndDate)")
return false
}
}
func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? {
slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min()
}
func getNextEarliestAvailableDate(from slots: [TimeMatch]) -> [(Int, Date)] {
let byCourt = Dictionary(grouping: slots, by: { $0.courtIndex })
return (byCourt.keys.flatMap { courtIndex in
let matchesByCourt = byCourt[courtIndex]?.sorted(by: \.startDate)
let lastMatch = matchesByCourt?.last
var results = [(Int, Date)]()
if let courtFreeDate = lastMatch?.estimatedEndDate(includeBreakTime: false) {
results.append((courtIndex, courtFreeDate))
}
return results
}
)
}
func getAvailableCourts(from matches: [Match]) -> [(Int, Date)] {
let validMatches = matches.filter({ $0.courtIndex != nil && $0.startDate != nil })
let byCourt = Dictionary(grouping: validMatches, by: { $0.courtIndex! })
return (byCourt.keys.flatMap { court in
let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!)
let lastMatch = matchesByCourt?.last
var results = [(Int, Date)]()
if let courtFreeDate = lastMatch?.estimatedEndDate(additionalEstimationDuration) {
results.append((court, courtFreeDate))
}
return results
}
)
}
func roundDispatcher(flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher {
var slots = [TimeMatch]()
var _startDate: Date?
var rotationIndex = 0
var availableMatchs = flattenedMatches.filter({ $0.startDate == nil })
let courtsUnavailability = courtsUnavailability
var issueFound: Bool = false
// Log start of the function
print("Starting roundDispatcher with \(availableMatchs.count) matches and \(courtsAvailable) courts available")
flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in
if _startDate == nil {
_startDate = match.startDate
} else if match.startDate! > _startDate! {
_startDate = match.startDate
rotationIndex += 1
}
let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime)
slots.append(timeMatch)
}
if !slots.isEmpty {
rotationIndex += 1
}
var freeCourtPerRotation = [Int: [Int]]()
var courts = initialCourts ?? Array(courtsAvailable)
var shouldStartAtDispatcherDate = rotationIndex > 0
var suitableDate: Date?
while !availableMatchs.isEmpty && !issueFound && rotationIndex < 50 {
freeCourtPerRotation[rotationIndex] = []
let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 })
var rotationStartDate: Date
if previousRotationSlots.isEmpty && rotationIndex > 0 {
let computedSuitableDate = slots.sorted(by: \.computedEndDateForSorting).last?.computedEndDateForSorting
print("Previous rotation was empty, find a suitable rotationStartDate \(suitableDate)")
rotationStartDate = suitableDate ?? computedSuitableDate ?? dispatcherStartDate
} else {
rotationStartDate = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate
}
if shouldStartAtDispatcherDate {
rotationStartDate = dispatcherStartDate
shouldStartAtDispatcherDate = false
} else {
courts = rotationIndex == 0 ? courts : Array(courtsAvailable)
}
courts.sort()
// Log courts availability and start date
print("Courts available at rotation \(rotationIndex): \(courts)")
print("Rotation start date: \(rotationStartDate)")
// Check for court availability and break time conflicts
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty {
print("Handling break time conflicts or waiting for free courts")
let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }
var previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
var previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false)
if let courtsUnavailability, previousEndDate != nil {
previousEndDate = getFirstFreeCourt(startDate: previousEndDate!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate
}
if let courtsUnavailability, previousEndDateNoBreak != nil {
previousEndDateNoBreak = getFirstFreeCourt(startDate: previousEndDateNoBreak!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate
}
let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak }
if let previousEndDate, let previousEndDateNoBreak {
let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate)
let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak)
print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)")
let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60)
var difference = differenceWithBreak
if differenceWithBreak <= 0, accountUpperBracketBreakTime == false {
difference = differenceWithoutBreak
} else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds {
difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak)
}
print("Final difference to evaluate: \(difference)")
if (difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate) || difference < 0 {
print("""
Adjusting rotation start:
- Initial rotationStartDate: \(rotationStartDate)
- Adjusted by difference: \(difference)
- Adjusted rotationStartDate: \(rotationStartDate.addingTimeInterval(-difference))
- PreviousEndDate: \(previousEndDate)
""")
courts.removeAll(where: { freeCourtPreviousRotation.contains($0) })
freeCourtPerRotation[rotationIndex] = courts
courts = freeCourtPreviousRotation
rotationStartDate = rotationStartDate.addingTimeInterval(-difference)
}
}
} else if let firstMatch = availableMatchs.first {
let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable))).isEmpty {
print("Issue: All courts unavailable in this rotation")
if let courtsUnavailability {
let computedStartDateAndCourts = getFirstFreeCourt(startDate: rotationStartDate, duration: duration, courts: courts, courtsUnavailability: courtsUnavailability)
rotationStartDate = computedStartDateAndCourts.earliestFreeDate
courts = computedStartDateAndCourts.availableCourts
} else {
issueFound = true
}
} else {
courts = Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable)))
}
}
// Dispatch courts and schedule matches
suitableDate = dispatchCourts(courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
rotationIndex += 1
}
// Organize matches in slots
var organizedSlots = [TimeMatch]()
for i in 0..<rotationIndex {
let courtsSorted = slots.filter { $0.rotationIndex == i }.map { $0.courtIndex }.sorted()
let courts = randomizeCourts ? courtsSorted.shuffled() : courtsSorted
var matches = slots.filter { $0.rotationIndex == i }.sorted(using: .keyPath(\.courtIndex))
for j in 0..<matches.count {
matches[j].courtIndex = courts[j]
organizedSlots.append(matches[j])
}
}
print("Finished roundDispatcher with \(organizedSlots.count) scheduled matches")
return MatchDispatcher(timedMatches: organizedSlots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound)
}
func dispatchCourts(courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) -> Date {
var matchPerRound = [String: Int]()
var minimumTargetedEndDate = rotationStartDate
// Log dispatch attempt
print("Dispatching courts for rotation \(rotationIndex) with start date \(rotationStartDate) and available courts \(courts.sorted())")
for (courtPosition, courtIndex) in courts.sorted().enumerated() {
if let firstMatch = availableMatchs.first(where: { match in
print("Trying to find a match for court \(courtIndex) in rotation \(rotationIndex)")
let roundObject = match.roundObject!
let duration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.contains(courtIndex) {
print("Returning false: Court \(courtIndex) unavailable due to schedule conflicts during \(rotationStartDate).")
return false
}
let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate)
if !canBePlayed {
print("Returning false: Match \(match.roundAndMatchTitle()) can't be played due to constraints.")
return false
}
let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0
let roundMatchesCount = roundObject.playedMatches().count
if shouldHandleUpperRoundSlice {
if roundObject.parent == nil && roundMatchesCount > courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) {
print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).")
return false
}
}
let indexInRound = match.indexInRound()
if shouldTryToFillUpCourtsAvailable == false {
if roundObject.parent == nil && roundObject.index > 1 && indexInRound == 0, let nextMatch = match.next() {
var nextMinimumTargetedEndDate = minimumTargetedEndDate
if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &nextMinimumTargetedEndDate) {
print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).")
return true
} else {
print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).")
return false
}
}
}
print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).")
return canBePlayed
}) {
print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)")
matchPerRound[firstMatch.roundObject!.id, default: 0] += 1
let timeMatch = TimeMatch(
matchID: firstMatch.id,
rotationIndex: rotationIndex,
courtIndex: courtIndex,
startDate: rotationStartDate,
durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration),
minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime
)
slots.append(timeMatch)
availableMatchs.removeAll(where: { $0.id == firstMatch.id })
} else {
print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.")
freeCourtPerRotation[rotationIndex]?.append(courtIndex)
}
}
if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count {
print("All courts in rotation \(rotationIndex) are free, minimumTargetedEndDate : \(minimumTargetedEndDate)")
}
if let courtsUnavailability {
let computedStartDateAndCourts = getFirstFreeCourt(startDate: minimumTargetedEndDate, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability)
return computedStartDateAndCourts.earliestFreeDate
}
return minimumTargetedEndDate
}
@discardableResult func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool {
let upperRounds: [Round] = tournament.rounds()
let allMatches: [Match] = tournament.allMatches().filter({ $0.hasEnded() == false && $0.hasStarted() == false })
var rounds = [Round]()
if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() {
rounds.append(groupStageLoserBracketRound)
}
if shouldEndRoundBeforeStartingNext {
rounds.append(contentsOf: upperRounds.flatMap {
[$0] + $0.loserRoundsAndChildren()
})
} else {
rounds.append(contentsOf: upperRounds.map {
$0
} + upperRounds.flatMap {
$0.loserRoundsAndChildren()
})
}
let flattenedMatches = rounds.flatMap { round in
round._matches().filter({ $0.disabled == false && $0.hasEnded() == false && $0.hasStarted() == false }).sorted(by: \.index)
}
flattenedMatches.forEach({
if (roundId == nil && matchId == nil) || $0.startDate?.isEarlierThan(startDate) == false {
$0.startDate = nil
$0.removeCourt()
$0.confirmed = false
}
})
// if let roundId {
// if let round : Round = Store.main.findById(roundId) {
// let matches = round._matches().filter({ $0.disabled == false }).sorted(by: \.index)
// round.resetFromRoundAllMatchesStartDate()
// flattenedMatches = matches + flattenedMatches
// }
//
// } else if let matchId {
// if let match : Match = Store.main.findById(matchId) {
// if let round = match.roundObject {
// round.resetFromRoundAllMatchesStartDate(from: match)
// }
// flattenedMatches = [match] + flattenedMatches
// }
// }
if let roundId, let matchId {
//todo
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId && $0.id == matchId }) {
flattenedMatches[index...].forEach {
$0.startDate = nil
$0.removeCourt()
$0.confirmed = false
}
}
} else if let roundId {
//todo
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId }) {
flattenedMatches[index...].forEach {
$0.startDate = nil
$0.removeCourt()
$0.confirmed = false
}
}
}
let matches: [Match] = allMatches.filter { $0.startDate?.isEarlierThan(startDate) == true && $0.startDate?.dayInt == startDate.dayInt }
let usedCourts = getAvailableCourts(from: matches)
let initialCourts: [Int] = usedCourts.filter { (court, availableDate) in
availableDate <= startDate
}.sorted(by: \.1).compactMap { $0.0 }
let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts
print("initial available courts at beginning: \(courts ?? [])")
let roundDispatch = self.roundDispatcher(flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts)
roundDispatch.timedMatches.forEach { matchSchedule in
if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) {
match.startDate = matchSchedule.startDate
match.setCourt(matchSchedule.courtIndex)
}
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: allMatches)
} catch {
Logger.error(error)
}
return roundDispatch.issueFound
}
func courtsUnavailable(startDate: Date, duration: Int, courtsUnavailability: [DateInterval]?) -> [Int] {
let endDate = startDate.addingTimeInterval(Double(duration) * 60)
guard let courtsUnavailability else { return [] }
let groupedBy = Dictionary(grouping: courtsUnavailability, by: { $0.courtIndex })
let courts = groupedBy.keys
return courts.filter {
courtUnavailable(courtIndex: $0, from: startDate, to: endDate, source: courtsUnavailability)
}
}
func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date, source: [DateInterval]) -> Bool {
let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex })
return courtLockedSchedule.anySatisfy({ dateInterval in
let range = startDate..<endDate
return dateInterval.range.overlaps(range)
})
}
func getFirstFreeCourt(startDate: Date, duration: Int, courts: [Int], courtsUnavailability: [DateInterval]) -> (earliestFreeDate: Date, availableCourts: [Int]) {
var earliestEndDate: Date?
var availableCourtsAtEarliest: [Int] = []
// Iterate through each court and find the earliest time it becomes free
for courtIndex in courts {
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
var isAvailable = true
for interval in unavailabilityForCourt {
if interval.startDate <= startDate && interval.endDate > startDate {
isAvailable = false
if let currentEarliest = earliestEndDate {
earliestEndDate = min(currentEarliest, interval.endDate)
} else {
earliestEndDate = interval.endDate
}
}
}
// If the court is available at the start date, add it to the list of available courts
if isAvailable {
availableCourtsAtEarliest.append(courtIndex)
}
}
// If there are no unavailable courts, return the original start date and all courts
if let earliestEndDate = earliestEndDate {
// Find which courts will be available at the earliest free date
let courtsAvailableAtEarliest = courts.filter { courtIndex in
let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex }
return unavailabilityForCourt.allSatisfy { $0.endDate <= earliestEndDate }
}
return (earliestFreeDate: earliestEndDate, availableCourts: courtsAvailableAtEarliest)
} else {
// If no courts were unavailable, all courts are available at the start date
return (earliestFreeDate: startDate.addingTimeInterval(Double(duration) * 60), availableCourts: courts)
}
}
func updateSchedule(tournament: Tournament) -> Bool {
if tournament.courtCount < courtsAvailable.count {
courtsAvailable = Set(tournament.courtsAvailable())
}
var lastDate = tournament.startDate
if tournament.groupStageCount > 0 {
lastDate = updateGroupStageSchedule(tournament: tournament)
}
if tournament.groupStages(atStep: 1).isEmpty == false {
lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate)
}
return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate)
}
}
struct GroupStageTimeMatch {
let matchID: String
let rotationIndex: Int
var courtIndex: Int
let groupIndex: Int
}
struct TimeMatch {
let matchID: String
let rotationIndex: Int
var courtIndex: Int
var startDate: Date
var durationLeft: Int //in minutes
var minimumBreakTime: Int //in minutes
func estimatedEndDate(includeBreakTime: Bool) -> Date {
let minutesToAdd = Double(durationLeft + (includeBreakTime ? minimumBreakTime : 0))
return startDate.addingTimeInterval(minutesToAdd * 60.0)
}
var computedEndDateForSorting: Date {
estimatedEndDate(includeBreakTime: false)
}
}
struct GroupStageMatchDispatcher {
let timedMatches: [GroupStageTimeMatch]
let freeCourtPerRotation: [Int: [Int]]
let rotationCount: Int
let groupLastRotation: [Int: Int]
}
struct MatchDispatcher {
let timedMatches: [TimeMatch]
let freeCourtPerRotation: [Int: [Int]]
let rotationCount: Int
let issueFound: Bool
}
extension Match {
func teamIds() -> [String] {
return teams().map { $0.id }
}
func containsTeamId(_ id: String) -> Bool {
return teamIds().contains(id)
}
func containsTeamIndex(_ id: String) -> Bool {
matchUp().contains(id)
}
func matchUp() -> [String] {
guard let groupStageObject else {
return []
}
return groupStageObject._matchUp(for: index).map { groupStageObject.id + "_\($0)" }
}
}

@ -0,0 +1,66 @@
//
// MockData.swift
// PadelClub
//
// Created by Razmig Sarkissian on 20/03/2024.
//
import Foundation
extension Court {
static func mock() -> Court {
Court(index: 0, club: "", name: "Test")
}
}
extension Event {
static func mock() -> Event {
Event()
}
}
extension Club {
static func mock() -> Club {
Club(name: "AUC", acronym: "AUC")
}
static func newEmptyInstance() -> Club {
Club(name: "", acronym: "")
}
}
extension GroupStage {
static func mock() -> GroupStage {
GroupStage(tournament: "", index: 0, size: 4)
}
}
extension Round {
static func mock() -> Round {
Round(tournament: "", index: 0)
}
}
extension Tournament {
static func mock() -> Tournament {
return Tournament(groupStageSortMode: .snake, teamSorting: .inscriptionDate, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .senior)
}
}
extension Match {
static func mock() -> Match {
return Match(index: 0)
}
}
extension TeamRegistration {
static func mock() -> TeamRegistration {
return TeamRegistration(tournament: "")
}
}
extension PlayerRegistration {
static func mock() -> PlayerRegistration {
return PlayerRegistration(firstName: "Raz", lastName: "Shark", sex: .male)
}
}

@ -0,0 +1,104 @@
//
// MonthData.swift
// PadelClub
//
// Created by Razmig Sarkissian on 18/04/2024.
//
import Foundation
import SwiftUI
import LeStorage
@Observable
final class MonthData : ModelObject, Storable {
static func resourceName() -> String { return "month-data" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
private(set) var id: String = Store.randomId()
private(set) var monthKey: String
private(set) var creationDate: Date
var maleUnrankedValue: Int? = nil
var femaleUnrankedValue: Int? = nil
var maleCount: Int? = nil
var femaleCount: Int? = nil
var anonymousCount: Int? = nil
var incompleteMode: Bool = false
var dataModelIdentifier: String?
var fileModelIdentifier: String?
init(monthKey: String) {
self.monthKey = monthKey
self.creationDate = Date()
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: ._id)
monthKey = try container.decode(String.self, forKey: ._monthKey)
creationDate = try container.decode(Date.self, forKey: ._creationDate)
maleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._maleUnrankedValue)
femaleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._femaleUnrankedValue)
maleCount = try container.decodeIfPresent(Int.self, forKey: ._maleCount)
femaleCount = try container.decodeIfPresent(Int.self, forKey: ._femaleCount)
anonymousCount = try container.decodeIfPresent(Int.self, forKey: ._anonymousCount)
incompleteMode = try container.decodeIfPresent(Bool.self, forKey: ._incompleteMode) ?? false
dataModelIdentifier = try container.decodeIfPresent(String.self, forKey: ._dataModelIdentifier) ?? nil
fileModelIdentifier = try container.decodeIfPresent(String.self, forKey: ._fileModelIdentifier) ?? nil
}
func total() -> Int {
return (maleCount ?? 0) + (femaleCount ?? 0)
}
static func calculateCurrentUnrankedValues(fromDate: Date) async {
let fileURL = SourceFileManager.shared.allFiles(true).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
print("calculateCurrentUnrankedValues", fromDate.monthYearFormatted, fileURL?.path())
let fftImportingUncomplete = fileURL?.fftImportingUncomplete()
let fftImportingMaleUnrankValue = fileURL?.fftImportingMaleUnrankValue()
let incompleteMode = fftImportingUncomplete != nil
let lastDataSourceMaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: true)
let lastDataSourceFemaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: false)
let anonymousCount = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
await MainActor.run {
let lastDataSource = URL.importDateFormatter.string(from: fromDate)
let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource)
currentMonthData.dataModelIdentifier = PersistenceController.getModelVersion()
currentMonthData.fileModelIdentifier = fileURL?.fileModelIdentifier()
currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0
currentMonthData.incompleteMode = incompleteMode
currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1
currentMonthData.femaleUnrankedValue = lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = lastDataSourceFemaleUnranked?.1
currentMonthData.anonymousCount = anonymousCount
do {
try DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
} catch {
Logger.error(error)
}
}
}
override func deleteDependencies() throws {
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _monthKey = "monthKey"
case _creationDate = "creationDate"
case _maleUnrankedValue = "maleUnrankedValue"
case _femaleUnrankedValue = "femaleUnrankedValue"
case _maleCount = "maleCount"
case _femaleCount = "femaleCount"
case _anonymousCount = "anonymousCount"
case _incompleteMode = "incompleteMode"
case _dataModelIdentifier = "dataModelIdentifier"
case _fileModelIdentifier = "fileModelIdentifier"
}
}

@ -0,0 +1,757 @@
//
// PlayerRegistration.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
@Observable
final class PlayerRegistration: ModelObject, Storable {
static func resourceName() -> String { "player-registrations" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = ["teamRegistration"]
var id: String = Store.randomId()
var teamRegistration: String?
var firstName: String
var lastName: String
var licenceId: String?
var rank: Int?
var paymentType: PlayerPaymentType?
var sex: PlayerSexType?
var tournamentPlayed: Int?
var points: Double?
var clubName: String?
var ligueName: String?
var assimilation: String?
var phoneNumber: String?
var email: String?
var birthdate: String?
var computedRank: Int = 0
var source: PlayerDataSource?
var hasArrived: Bool = false
var coach: Bool = false
var captain: Bool = false
var clubCode: String?
var sourceName: String?
var isNveq: Bool = false
var isEQConfirmed: Bool?
var duplicatePlayers: Int?
var isYearValid: Bool?
func localizedSourceLabel() -> String {
switch source {
case .frenchFederation, .frenchFederationVerified, .frenchFederationEQVerified:
return "Via la base fédérale"
case .beachPadel:
return "Via le fichier beach-padel"
case .onlineRegistration:
return "Via un inscription en ligne"
case nil:
return "Manuellement"
}
}
init(teamRegistration: String? = nil, firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, paymentType: PlayerPaymentType? = nil, sex: PlayerSexType? = nil, tournamentPlayed: Int? = nil, points: Double? = nil, clubName: String? = nil, ligueName: String? = nil, assimilation: String? = nil, phoneNumber: String? = nil, email: String? = nil, birthdate: String? = nil, computedRank: Int = 0, source: PlayerDataSource? = nil, hasArrived: Bool = false, coach: Bool = false, captain: Bool = false) {
self.teamRegistration = teamRegistration
self.firstName = firstName
self.lastName = lastName
self.licenceId = licenceId
self.rank = rank
self.paymentType = paymentType
self.sex = sex
self.tournamentPlayed = tournamentPlayed
self.points = points
self.clubName = clubName
self.ligueName = ligueName
self.assimilation = assimilation
self.phoneNumber = phoneNumber
self.email = email
self.birthdate = birthdate
self.computedRank = computedRank
self.source = source
self.hasArrived = hasArrived
self.captain = captain
self.coach = coach
}
internal init(importedPlayer: ImportedPlayer) {
self.teamRegistration = ""
self.firstName = (importedPlayer.firstName ?? "").prefixTrimmed(50).capitalized
self.lastName = (importedPlayer.lastName ?? "").prefixTrimmed(50).uppercased()
self.licenceId = importedPlayer.license?.prefixTrimmed(50) ?? nil
self.rank = Int(importedPlayer.rank)
self.sex = importedPlayer.male ? .male : .female
self.tournamentPlayed = importedPlayer.tournamentPlayed
self.points = importedPlayer.getPoints()
self.clubName = importedPlayer.clubName?.prefixTrimmed(200)
self.ligueName = importedPlayer.ligueName?.prefixTrimmed(200)
self.assimilation = importedPlayer.assimilation?.prefixTrimmed(50)
self.source = .frenchFederation
self.birthdate = importedPlayer.birthYear?.prefixTrimmed(50)
}
internal init?(federalData: [String], sex: Int, sexUnknown: Bool) {
let _lastName = federalData[0].trimmed.uppercased()
let _firstName = federalData[1].trimmed.capitalized
if _lastName.isEmpty && _firstName.isEmpty { return nil }
lastName = _lastName.prefixTrimmed(50)
firstName = _firstName.prefixTrimmed(50)
birthdate = federalData[2].formattedAsBirthdate().prefixTrimmed(50)
licenceId = federalData[3].prefixTrimmed(50)
clubName = federalData[4].prefixTrimmed(200)
let stringRank = federalData[5]
if stringRank.isEmpty {
rank = nil
} else {
rank = Int(stringRank)
}
let _email = federalData[6]
if _email.isEmpty == false {
self.email = _email.prefixTrimmed(50)
}
let _phoneNumber = federalData[7]
if _phoneNumber.isEmpty == false {
self.phoneNumber = _phoneNumber.prefixTrimmed(50)
}
source = .beachPadel
if sexUnknown {
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) {
self.sex = .female
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) {
self.sex = .male
} else {
self.sex = nil
}
} else {
self.sex = PlayerSexType(rawValue: sex)
}
}
var tournamentStore: TournamentStore {
if let store = self.store as? TournamentStore {
return store
}
fatalError("missing store for \(String(describing: type(of: self)))")
}
var computedAge: Int? {
if let birthdate {
let components = birthdate.components(separatedBy: "/")
if let age = components.last, let ageInt = Int(age) {
let year = Calendar.current.getSportAge()
if age.count == 2 { //si l'année est sur 2 chiffres dans le fichier
if ageInt < 23 {
return year - 2000 - ageInt
} else {
return year - 2000 + 100 - ageInt
}
} else { //si l'année est représenté sur 4 chiffres
return year - ageInt
}
}
}
return nil
}
func fetchUnrankPlayerData() async throws -> Player? {
guard let licence = licenceId?.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).trimmed.strippedLicense else {
return nil
}
return try await fetchPlayerData(for: licence)?.first
}
func pasteData(_ exportFormat: ExportFormat = .rawText) -> String {
switch exportFormat {
case .rawText:
return [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: exportFormat.separator())
case .csv:
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator())
case .championship:
let values = [
lastName.uppercased(),
firstName.capitalized,
isVerified(),
"=\"" + formattedLicense() + "\"",
"\(computedRank)",
isNveq ? "NVEQ" : "EQ",
]
.joined(separator: exportFormat.separator())
return values
}
}
func isVerified() -> String {
switch source {
case .frenchFederationVerified:
return "ok"
case .frenchFederationEQVerified:
return "ok2"
default:
return ""
}
}
func championshipAlerts(tournament: Tournament, allPlayers: [(String, String)], forRegional: Bool) -> [ChampionshipAlert] {
var alerts = [ChampionshipAlert]()
if forRegional, source != .frenchFederationEQVerified {
if isNveq == false && (isEQConfirmed == false || isEQConfirmed == nil) {
alerts.append(.notEQ(isEQConfirmed, self))
}
}
if isYearValid == nil || isYearValid == false {
alerts.append(.isYearValid(isYearValid, self))
}
if duplicatePlayers == nil {
let teamClubCode = self.team()?.clubCode
let strippedLicense = self.licenceId?.strippedLicense
duplicatePlayers = allPlayers.count(where: { strippedLicense == $0.0 && teamClubCode != $0.1 })
}
if let duplicatePlayers {
if duplicatePlayers > 0 {
print("doublon found \(duplicatePlayers)")
alerts.append(.duplicate(duplicatePlayers, self))
}
}
if isUnranked() && source == nil {
alerts.append(.unranked(self))
} else if source != .frenchFederationVerified && source != .frenchFederationEQVerified {
if tournament.federalTournamentAge == .senior {
if tournament.tournamentCategory == .men && isMalePlayer() == false {
alerts.append(.playerSexInvalid(self))
}
if tournament.tournamentCategory == .women && isMalePlayer() {
alerts.append(.playerSexInvalid(self))
}
}
if let computedAge, tournament.federalTournamentAge.isAgeValid(age: computedAge) == false {
alerts.append(.playerAgeInvalid(self))
}
if isClubCodeOK() == false {
alerts.append(.playerClubInvalid(self))
}
if isNameOK() == false {
alerts.append(.playerNameInvalid(self))
}
if isLicenceOK() == false {
alerts.append(.playerLicenseInvalid(self))
}
}
return alerts
}
func isClubCodeOK() -> Bool {
team()?.clubCode?.trimmed.canonicalVersion == clubCode?.trimmed.canonicalVersion
}
func isLicenceOK() -> Bool {
guard let licenceId else { return false }
let licenceIdTrimmed = licenceId.trimmed
guard licenceIdTrimmed.strippedLicense != nil else { return false }
if licenceIdTrimmed.hasLicenseKey() {
return licenceIdTrimmed.isLicenseNumber
} else {
return true
}
}
func isNameOK() -> Bool {
lastName.canonicalVersion == sourceName?.canonicalVersion
}
func isPlaying() -> Bool {
team()?.isPlaying() == true
}
func contains(_ searchField: String) -> Bool {
let nameComponents = searchField.canonicalVersion.split(separator: " ")
if nameComponents.count > 1 {
let pairs = nameComponents.pairs()
return pairs.contains(where: {
(firstName.canonicalVersion.localizedCaseInsensitiveContains(String($0)) &&
lastName.canonicalVersion.localizedCaseInsensitiveContains(String($1))) ||
(firstName.canonicalVersion.localizedCaseInsensitiveContains(String($1)) &&
lastName.canonicalVersion.localizedCaseInsensitiveContains(String($0)))
})
} else {
return nameComponents.contains { component in
firstName.canonicalVersion.localizedCaseInsensitiveContains(component) ||
lastName.canonicalVersion.localizedCaseInsensitiveContains(component)
}
}
}
func isSameAs(_ player: PlayerRegistration) -> Bool {
firstName.trimmedMultiline.canonicalVersion.localizedCaseInsensitiveCompare(player.firstName.trimmedMultiline.canonicalVersion) == .orderedSame &&
lastName.trimmedMultiline.canonicalVersion.localizedCaseInsensitiveCompare(player.lastName.trimmedMultiline.canonicalVersion) == .orderedSame
}
func tournament() -> Tournament? {
guard let tournament = team()?.tournament else { return nil }
return Store.main.findById(tournament)
}
func team() -> TeamRegistration? {
guard let teamRegistration else { return nil }
return self.tournamentStore.teamRegistrations.findById(teamRegistration)
}
func isHere() -> Bool {
hasArrived
}
func hasPaid() -> Bool {
paymentType != nil
}
func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
case .wide, .title:
return lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized
case .short:
let names = lastName.components(separatedBy: .whitespaces)
if lastName.components(separatedBy: .whitespaces).count > 1 {
if let firstLongWord = names.first(where: { $0.count > 3 }) {
return firstLongWord.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "."
}
}
return lastName.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "."
}
}
func isImported() -> Bool {
source == .beachPadel
}
func isValidLicenseNumber(year: Int) -> Bool {
guard let licenceId else { return false }
guard licenceId.isLicenseNumber else { return false }
guard licenceId.suffix(6) == "(\(year))" else { return false }
return true
}
@objc
var canonicalName: String {
playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased()
}
func rankLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if let rank, rank > 0 {
if rank != computedRank {
return computedRank.formatted() + " (" + rank.formatted() + ")"
} else {
return rank.formatted()
}
} else {
return "non classé" + (isMalePlayer() ? "" : "e")
}
}
func verifyEQ(from sources: [CSVParser]) async throws {
#if DEBUG_TIME //DEBUGING 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
if let dataFound = try await history(from: sources) {
print("dataFound.clubCode == clubCode", dataFound.clubCode, clubCode)
isEQConfirmed = dataFound.clubCode == clubCode
} else {
print("not found")
isEQConfirmed = nil
}
}
func updateRank(from sources: [CSVParser], lastRank: Int) async throws {
#if DEBUG_TIME //DEBUGING 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
if let dataFound = try await history(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else {
rank = lastRank
}
}
func history(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func history()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let license = licenceId?.strippedLicense else {
return try await historyFromName(from: sources)
}
return await withTaskGroup(of: Line?.self) { group in
for source in sources.filter({ $0.maleData == isMalePlayer() }) {
group.addTask {
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first(where: { line in
line.rawValue.contains(";\(license);")
})
}
}
if let first = await group.first(where: { $0 != nil }) {
group.cancelAll()
return first
} else {
return nil
}
}
}
func historyFromName(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func historyFromName()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return await withTaskGroup(of: Line?.self) { group in
for source in sources.filter({ $0.maleData == isMalePlayer() }) {
group.addTask { [lastName, firstName] in
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first(where: { line in
line.rawValue.canonicalVersionWithPunctuation.contains(";\(lastName.canonicalVersionWithPunctuation);\(firstName.canonicalVersionWithPunctuation);")
})
}
}
if let first = await group.first(where: { $0 != nil }) {
group.cancelAll()
return first
} else {
return nil
}
}
}
func setComputedRank(in tournament: Tournament) {
if tournament.isAnimation() {
computedRank = rank ?? 0
return
}
let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 70_000
switch tournament.tournamentCategory {
case .men:
computedRank = isMalePlayer() ? currentRank : currentRank + PlayerRegistration.addon(for: currentRank, manMax: tournament.maleUnrankedValue ?? 0, womanMax: tournament.femaleUnrankedValue ?? 0)
default:
computedRank = currentRank
}
}
func isMalePlayer() -> Bool {
sex == .male
}
func validateLicenceId(_ year: Int) {
if let currentLicenceId = licenceId {
if currentLicenceId.trimmed.hasSuffix("(\(year-1))") {
self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)")
} else if let computedLicense = currentLicenceId.strippedLicense?.computedLicense {
self.licenceId = computedLicense + " (\(year))"
}
}
}
func hasHomonym() -> Bool {
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName)
fetchRequest.predicate = predicate
do {
let count = try federalContext.count(for: fetchRequest)
return count > 1
} catch {
}
return false
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _teamRegistration = "teamRegistration"
case _firstName = "firstName"
case _lastName = "lastName"
case _licenceId = "licenceId"
case _rank = "rank"
case _paymentType = "paymentType"
case _sex = "sex"
case _tournamentPlayed = "tournamentPlayed"
case _points = "points"
case _clubName = "clubName"
case _ligueName = "ligueName"
case _assimilation = "assimilation"
case _birthdate = "birthdate"
case _phoneNumber = "phoneNumber"
case _email = "email"
case _computedRank = "computedRank"
case _source = "source"
case _hasArrived = "hasArrived"
case _coach = "coach"
case _captain = "captain"
case _clubCode = "clubCode"
case _sourceName = "sourceName"
case _isNveq = "isNveq"
case _duplicatePlayers = "duplicatePlayers"
case _isYearValid = "isYearValid"
case _isEQConfirmed = "isEQConfirmed"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Non-optional properties
id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId()
firstName = try container.decode(String.self, forKey: ._firstName)
lastName = try container.decode(String.self, forKey: ._lastName)
computedRank = try container.decodeIfPresent(Int.self, forKey: ._computedRank) ?? 0
hasArrived = try container.decodeIfPresent(Bool.self, forKey: ._hasArrived) ?? false
coach = try container.decodeIfPresent(Bool.self, forKey: ._coach) ?? false
captain = try container.decodeIfPresent(Bool.self, forKey: ._captain) ?? false
// Optional properties
teamRegistration = try container.decodeIfPresent(String.self, forKey: ._teamRegistration)
licenceId = try container.decodeIfPresent(String.self, forKey: ._licenceId)
rank = try container.decodeIfPresent(Int.self, forKey: ._rank)
paymentType = try container.decodeIfPresent(PlayerPaymentType.self, forKey: ._paymentType)
sex = try container.decodeIfPresent(PlayerSexType.self, forKey: ._sex)
tournamentPlayed = try container.decodeIfPresent(Int.self, forKey: ._tournamentPlayed)
points = try container.decodeIfPresent(Double.self, forKey: ._points)
clubName = try container.decodeIfPresent(String.self, forKey: ._clubName)
ligueName = try container.decodeIfPresent(String.self, forKey: ._ligueName)
assimilation = try container.decodeIfPresent(String.self, forKey: ._assimilation)
phoneNumber = try container.decodeIfPresent(String.self, forKey: ._phoneNumber)
email = try container.decodeIfPresent(String.self, forKey: ._email)
birthdate = try container.decodeIfPresent(String.self, forKey: ._birthdate)
source = try container.decodeIfPresent(PlayerDataSource.self, forKey: ._source)
clubCode = try container.decodeIfPresent(String.self, forKey: ._clubCode)
isNveq = try container.decodeIfPresent(Bool.self, forKey: ._isNveq) ?? false
sourceName = try container.decodeIfPresent(String.self, forKey: ._sourceName)
isEQConfirmed = try container.decodeIfPresent(Bool.self, forKey: ._isEQConfirmed)
duplicatePlayers = try container.decodeIfPresent(Int.self, forKey: ._duplicatePlayers)
isYearValid = try container.decodeIfPresent(Bool.self, forKey: ._isYearValid)
sourceName = try container.decodeIfPresent(String.self, forKey: ._sourceName)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(teamRegistration, forKey: ._teamRegistration)
try container.encode(firstName, forKey: ._firstName)
try container.encode(lastName, forKey: ._lastName)
try container.encode(licenceId, forKey: ._licenceId)
try container.encode(rank, forKey: ._rank)
try container.encode(paymentType, forKey: ._paymentType)
try container.encode(sex, forKey: ._sex)
try container.encode(tournamentPlayed, forKey: ._tournamentPlayed)
try container.encode(points, forKey: ._points)
try container.encode(clubName, forKey: ._clubName)
try container.encode(ligueName, forKey: ._ligueName)
try container.encode(assimilation, forKey: ._assimilation)
try container.encode(phoneNumber, forKey: ._phoneNumber)
try container.encode(email, forKey: ._email)
try container.encode(birthdate, forKey: ._birthdate)
try container.encode(computedRank, forKey: ._computedRank)
try container.encode(source, forKey: ._source)
try container.encode(hasArrived, forKey: ._hasArrived)
try container.encode(captain, forKey: ._captain)
try container.encode(coach, forKey: ._coach)
try container.encode(clubCode, forKey: ._clubCode)
try container.encode(sourceName, forKey: ._sourceName)
try container.encode(isNveq, forKey: ._isNveq)
try container.encode(isEQConfirmed, forKey: ._isEQConfirmed)
try container.encode(duplicatePlayers, forKey: ._duplicatePlayers)
try container.encode(isYearValid, forKey: ._isYearValid)
}
enum PlayerDataSource: Int, Codable {
case frenchFederation = 0
case beachPadel = 1
case onlineRegistration = 2
case frenchFederationVerified = 3
case frenchFederationEQVerified = 4
}
enum PlayerSexType: Int, Hashable, CaseIterable, Identifiable, Codable {
init?(rawValue: Int?) {
guard let value = rawValue else { return nil }
self.init(rawValue: value)
}
var id: Self {
self
}
case female = 0
case male = 1
}
enum PlayerPaymentType: Int, CaseIterable, Identifiable, Codable {
init?(rawValue: Int?) {
guard let value = rawValue else { return nil }
self.init(rawValue: value)
}
var id: Self {
self
}
case cash = 0
case lydia = 1
case gift = 2
case check = 3
case paylib = 4
case bankTransfer = 5
case clubHouse = 6
case creditCard = 7
case forfeit = 8
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .check:
return "Chèque"
case .cash:
return "Cash"
case .lydia:
return "Lydia"
case .paylib:
return "Paylib"
case .bankTransfer:
return "Virement"
case .clubHouse:
return "Clubhouse"
case .creditCard:
return "CB"
case .forfeit:
return "Forfait"
case .gift:
return "Offert"
}
}
}
static func addon(for playerRank: Int, manMax: Int, womanMax: Int) -> Int {
switch playerRank {
case 0: return 0
case womanMax: return manMax - womanMax
case manMax: return 0
default:
return TournamentCategory.femaleInMaleAssimilationAddition(playerRank)
}
}
func insertOnServer() {
self.tournamentStore.playerRegistrations.writeChangeAndInsertOnServer(instance: self)
}
}
extension PlayerRegistration: Hashable {
static func == (lhs: PlayerRegistration, rhs: PlayerRegistration) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
extension PlayerRegistration: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
nil
}
func getFirstName() -> String {
firstName
}
func getLastName() -> String {
lastName
}
func getPoints() -> Double? {
self.points
}
func getRank() -> Int? {
rank
}
func isUnranked() -> Bool {
rank == nil
}
func formattedRank() -> String {
self.rankLabel()
}
func formattedLicense() -> String {
if let licenceId { return licenceId.computedLicense }
return "aucune licence"
}
var male: Bool {
isMalePlayer()
}
func getBirthYear() -> Int? {
nil
}
func getProgression() -> Int {
0
}
func getComputedRank() -> Int? {
computedRank
}
}

@ -0,0 +1,21 @@
# Procédure d'ajout de champ dans une classe
Dans Swift:
- Ajouter le champ dans classe
- Ajouter le champ dans le constructeur si possible
- Ajouter la codingKey correspondante
- Ajouter le champ dans l'encoding/decoding
- Ouvrir **ServerDataTests** et ajouter un test sur le champ
- Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple
Dans Django:
- Ajouter le champ dans la classe
- Si c'est un champ dans **CustomUser**:
- Ajouter le champ à la méthode fields_for_update
- Ajouter le champ dans UserSerializer > create > create_user dans serializers.py
- L'ajouter aussi dans admin.py si nécéssaire
- Faire le *makemigrations* + *migrate*
Enfin, revenir dans Xcode, ouvrir ServerDataTests et lancer le test mis à jour

@ -0,0 +1,860 @@
//
// Round.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
import SwiftUI
@Observable
final class Round: ModelObject, Storable {
static func resourceName() -> String { "rounds" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var tournament: String
var index: Int
var parent: String?
private(set) var format: MatchFormat?
var startDate: Date?
var groupStageLoserBracket: Bool = false
var loserBracketMode: LoserBracketMode = .automatic
var _cachedSeedInterval: SeedInterval?
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil, groupStageLoserBracket: Bool = false, loserBracketMode: LoserBracketMode = .automatic) {
self.tournament = tournament
self.index = index
self.parent = parent
self.format = matchFormat
self.startDate = startDate
self.groupStageLoserBracket = groupStageLoserBracket
self.loserBracketMode = loserBracketMode
}
// MARK: - Computed dependencies
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
func tournamentObject() -> Tournament? {
return Store.main.findById(tournament)
}
func _matches() -> [Match] {
return self.tournamentStore.matches.filter { $0.round == self.id }.sorted(by: \.index)
// return Store.main.filter { $0.round == self.id }
}
func getDisabledMatches() -> [Match] {
return self.tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true }
// return Store.main.filter { $0.round == self.id && $0.disabled == true }
}
// MARK: -
var matchFormat: MatchFormat {
get {
format ?? .defaultFormatForMatchType(.bracket)
}
set {
format = newValue
}
}
func hasStarted() -> Bool {
return playedMatches().anySatisfy({ $0.hasStarted() })
}
func hasEnded() -> Bool {
if isUpperBracket() {
return playedMatches().anySatisfy({ $0.hasEnded() == false }) == false
} else {
return enabledMatches().anySatisfy({ $0.hasEnded() == false }) == false
}
}
func upperMatches(ofMatch match: Match) -> [Match] {
if parent != nil, previousRound() == nil, let parentRound {
let matchIndex = match.index
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
return [parentRound.getMatch(atMatchIndexInRound: indexInRound * 2), parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1)].compactMap({ $0 })
}
return []
}
func previousMatches(ofMatch match: Match) -> [Match] {
guard let previousRound = previousRound() else { return [] }
return self.tournamentStore.matches.filter {
$0.round == previousRound.id && ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex())
}
// return Store.main.filter {
// ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex()) && $0.round == previousRound.id
// }
}
func precedentMatches(ofMatch match: Match) -> [Match] {
let upper = upperMatches(ofMatch: match)
if upper.isEmpty == false {
return upper
}
let previous : [Match] = previousMatches(ofMatch: match)
if previous.isEmpty == false && previous.allSatisfy({ $0.disabled }), let previousRound = previousRound() {
return previous.flatMap({ previousRound.precedentMatches(ofMatch: $0) })
} else {
return previous
}
}
func team(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? {
return roundProjectedTeam(team, inMatch: match, previousRound: previousRound)
}
func seed(_ team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? {
return self.tournamentStore.teamRegistrations.first(where: {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) == matchIndex
&& ($0.bracketPosition! % 2) == team.rawValue
})
}
func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] {
return self.tournamentStore.teamRegistrations.filter {
$0.tournament == tournament
&& $0.bracketPosition != nil
&& ($0.bracketPosition! / 2) == matchIndex
}
// return Store.main.filter(isIncluded: {
// $0.tournament == tournament
// && $0.bracketPosition != nil
// && ($0.bracketPosition! / 2) == matchIndex
// })
}
func seeds() -> [TeamRegistration] {
let initialMatchIndex = RoundRule.matchIndex(fromRoundIndex: index)
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index)
return self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) >= initialMatchIndex
&& ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches
}
}
func teamsOrSeeds() -> [TeamRegistration] {
let seeds = seeds()
if seeds.isEmpty {
return playedMatches().flatMap({ $0.teams() })
} else {
return seeds
}
}
func losers() -> [TeamRegistration] {
let teamIds: [String] = self._matches().compactMap { $0.losingTeamId }
return teamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) }
}
func winners() -> [TeamRegistration] {
let teamIds: [String] = self._matches().compactMap { $0.winningTeamId }
return teamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) }
}
func teams() -> [TeamRegistration] {
return playedMatches().flatMap({ $0.teams() })
}
func roundProjectedTeam(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func roundProjectedTeam", team.rawValue, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if isLoserBracket() == false, let seed = seed(team, inMatchIndex: match.index) {
return seed
}
switch team {
case .one:
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 }) {
return luckyLoser.team
} else if let previousMatch = topPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
if let teamId = previousMatch.winningTeamId {
return self.tournamentStore.teamRegistrations.findById(teamId)
} else if previousMatch.disabled {
return previousMatch.teams().first
}
} else if let parent = upperBracketTopMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
return tournamentStore.findById(parent)
}
case .two:
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 + 1 }) {
return luckyLoser.team
} else if let previousMatch = bottomPreviousRoundMatch(ofMatch: match, previousRound: previousRound) {
if let teamId = previousMatch.winningTeamId {
return self.tournamentStore.teamRegistrations.findById(teamId)
} else if previousMatch.disabled {
return previousMatch.teams().first
}
} else if let parent = upperBracketBottomMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId {
return tournamentStore.findById(parent)
}
}
return nil
}
func upperBracketTopMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func upperBracketTopMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let parentRound = parentRound
if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic {
return nil
}
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
if isLoserBracket(), previousRound == nil, let upperBracketTopMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2) {
return upperBracketTopMatch
}
return nil
}
func upperBracketBottomMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func upperBracketBottomMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let parentRound = parentRound
if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic {
return nil
}
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
if isLoserBracket(), previousRound == nil, let upperBracketBottomMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) {
return upperBracketBottomMatch
}
return nil
}
func topPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func topPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let previousRound else { return nil }
let topPreviousRoundMatchIndex = match.topPreviousRoundMatchIndex()
return self.tournamentStore.matches.first(where: {
$0.round == previousRound.id && $0.index == topPreviousRoundMatchIndex
})
}
func bottomPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func bottomPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let previousRound else { return nil }
let bottomPreviousRoundMatchIndex = match.bottomPreviousRoundMatchIndex()
return self.tournamentStore.matches.first(where: {
$0.round == previousRound.id && $0.index == bottomPreviousRoundMatchIndex
})
}
func getMatch(atMatchIndexInRound matchIndexInRound: Int) -> Match? {
self.tournamentStore.matches.first(where: {
let index = RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index)
return $0.round == id && index == matchIndexInRound
})
}
func enabledMatches() -> [Match] {
return self.tournamentStore.matches.filter { $0.round == self.id && $0.disabled == false }.sorted(by: \.index)
}
// func displayableMatches() -> [Match] {
//#if DEBUG //DEBUGING TIME
// let start = Date()
// defer {
// let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
// print("func displayableMatches of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
// }
//#endif
//
// if index == 0 && isUpperBracket() {
// var matches : [Match?] = [playedMatches().first]
// matches.append(loserRounds().first?.playedMatches().first)
// return matches.compactMap({ $0 })
// } else {
// return playedMatches()
// }
// }
func playedMatches() -> [Match] {
if isUpperBracket() {
return enabledMatches()
} else {
return _matches()
}
}
func previousRound() -> Round? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func previousRound of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return self.tournamentStore.rounds.first(where: { $0.parent == parent && $0.index == index + 1 })
}
func nextRound() -> Round? {
return self.tournamentStore.rounds.first(where: { $0.parent == parent && $0.index == index - 1 })
}
func loserRounds(forRoundIndex roundIndex: Int) -> [Round] {
return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount)
}
func loserRounds(forRoundIndex roundIndex: Int, loserRoundsAndChildren: [Round]) -> [Round] {
return loserRoundsAndChildren.filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount)
}
func isDisabled() -> Bool {
return _matches().allSatisfy({ $0.disabled })
}
func isRankDisabled() -> Bool {
return _matches().allSatisfy({ $0.disabled && $0.teamScores.isEmpty })
}
func resetFromRoundAllMatchesStartDate() {
_matches().forEach({
$0.startDate = nil
})
loserRoundsAndChildren().forEach { round in
round.resetFromRoundAllMatchesStartDate()
}
nextRound()?.resetFromRoundAllMatchesStartDate()
}
func resetFromRoundAllMatchesStartDate(from match: Match) {
let matches = _matches()
if let index = matches.firstIndex(where: { $0.id == match.id }) {
matches[index...].forEach { match in
match.startDate = nil
}
}
loserRoundsAndChildren().forEach { round in
round.resetFromRoundAllMatchesStartDate()
}
nextRound()?.resetFromRoundAllMatchesStartDate()
}
func getActiveLoserRound() -> Round? {
let rounds = loserRounds().filter({ $0.isDisabled() == false }).sorted(by: \.index).reversed()
return rounds.first(where: { $0.hasStarted() && $0.hasEnded() == false }) ?? rounds.first
}
func enableRound() {
_toggleRound(disable: false)
}
func disableRound() {
_toggleRound(disable: true)
}
private func _toggleRound(disable: Bool) {
let _matches = _matches()
_matches.forEach { match in
match.disabled = disable
match.resetMatch()
//we need to keep teamscores to handle disable ranking match round stuff
// do {
// try DataStore.shared.teamScores.delete(contentOfs: match.teamScores)
// } catch {
// Logger.error(error)
// }
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: _matches)
} catch {
Logger.error(error)
}
}
var cumulativeMatchCount: Int {
var totalMatches = playedMatches().count
if let parentRound {
totalMatches += parentRound.cumulativeMatchCount
}
return totalMatches
}
func initialRound() -> Round? {
if let parentRound {
return parentRound.initialRound()
} else {
return self
}
}
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? {
return enabledMatches().last?.estimatedEndDate(additionalEstimationDuration)
}
func getLoserRoundStartDate() -> Date? {
return loserRoundsAndChildren().first(where: { $0.isDisabled() == false })?.enabledMatches().first?.startDate
}
func estimatedLoserRoundEndDate(_ additionalEstimationDuration: Int) -> Date? {
let lastMatch = loserRoundsAndChildren().last(where: { $0.isDisabled() == false })?.enabledMatches().last
return lastMatch?.estimatedEndDate(additionalEstimationDuration)
}
func disabledMatches() -> [Match] {
return _matches().filter({ $0.disabled })
}
func allLoserRoundMatches() -> [Match] {
loserRoundsAndChildren().flatMap({ $0._matches() })
}
var theoryCumulativeMatchCount: Int {
var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index)
if let parentRound {
totalMatches += parentRound.theoryCumulativeMatchCount
}
return totalMatches
}
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let _cachedSeedInterval { return _cachedSeedInterval.localizedLabel(displayStyle) }
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func correspondingLoserRoundTitle()", duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
let seedsAfterThisRound: [TeamRegistration] = self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
}
// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: {
// $0.tournament == tournament
// && $0.bracketPosition != nil
// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
// })
var seedsCount = seedsAfterThisRound.count
if seedsAfterThisRound.isEmpty {
let nextRoundsDisableMatches = nextRoundsDisableMatches()
seedsCount = disabledMatches().count - nextRoundsDisableMatches
}
let playedMatches = playedMatches()
let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount)
_cachedSeedInterval = seedInterval
return seedInterval.localizedLabel(displayStyle)
}
func hasNextRound() -> Bool {
return nextRound()?.isRankDisabled() == false
}
func pasteData() -> String {
var data: [String] = []
data.append(self.roundTitle())
playedMatches().forEach { match in
data.append(match.matchTitle())
data.append(match.team(.one)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
data.append(match.team(.two)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----")
}
return data.joined(separator: "\n")
}
func seedInterval(initialMode: Bool = false) -> SeedInterval? {
if initialMode == false, let _cachedSeedInterval { return _cachedSeedInterval }
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func seedInterval(initialMode)", initialMode, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if isUpperBracket() {
if index == 0 { return SeedInterval(first: 1, last: 2) }
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
if initialMode {
let playedMatches = RoundRule.numberOfMatches(forRoundIndex: index)
let seedInterval = SeedInterval(first: playedMatches + 1, last: playedMatches * 2)
//print(seedInterval.localizedLabel())
return seedInterval
} else {
let seedsAfterThisRound : [TeamRegistration] = self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
}
var seedsCount = seedsAfterThisRound.count
if seedsAfterThisRound.isEmpty {
let nextRoundsDisableMatches = nextRoundsDisableMatches()
seedsCount = disabledMatches().count - nextRoundsDisableMatches
}
let playedMatches = playedMatches()
//print("playedMatches \(playedMatches)", initialMode, parent, parentRound?.roundTitle(), seedsAfterThisRound.count)
let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount)
//print(seedInterval.localizedLabel())
_cachedSeedInterval = seedInterval
return seedInterval
}
}
if let previousRound = previousRound() {
if (previousRound.enabledMatches().isEmpty == false || initialMode) {
return previousRound.seedInterval(initialMode: initialMode)?.chunks()?.first
} else {
return previousRound.seedInterval(initialMode: initialMode)
}
} else if let parentRound {
if parentRound.isUpperBracket() {
return parentRound.seedInterval(initialMode: initialMode)
}
return parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last
}
return nil
}
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String {
if groupStageLoserBracket {
return "Classement Poules"
}
if parent != nil {
if let seedInterval = seedInterval(initialMode: initialMode) {
return seedInterval.localizedLabel(displayStyle)
}
print("Round pas trouvé", id, parent, index)
return "Match de classement"
}
return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle)
}
func updateTournamentState() {
if let tournamentObject = tournamentObject(), index == 0, isUpperBracket(), hasEnded() {
tournamentObject.endDate = Date()
do {
try DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject)
} catch {
Logger.error(error)
}
}
}
func roundStatus() -> String {
let hasEnded = hasEnded()
if hasStarted() && hasEnded == false {
return "en cours"
} else if hasEnded {
return "terminée"
} else {
return "à démarrer"
}
}
func loserRounds() -> [Round] {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func loserRounds: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return self.tournamentStore.rounds.filter( { $0.parent == id }).sorted(by: \.index).reversed()
}
func loserRoundsAndChildren() -> [Round] {
let loserRounds = loserRounds()
return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() })
}
func isUpperBracket() -> Bool {
return parent == nil && groupStageLoserBracket == false
}
func isLoserBracket() -> Bool {
return parent != nil || groupStageLoserBracket
}
func deleteLoserBracket() {
do {
let loserRounds = loserRounds()
try self.tournamentStore.rounds.delete(contentOfs: loserRounds)
} catch {
Logger.error(error)
}
}
func buildLoserBracket() {
guard loserRounds().isEmpty else { return }
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
guard currentRoundMatchCount > 1 else { return }
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
var loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat
if let parentRound {
loserBracketMatchFormat = tournamentObject()?.loserBracketSmartMatchFormat(parentRound.index)
}
let rounds = (0..<roundCount).map { //index 0 is the final
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat)
round.parent = id //parent
return round
}
do {
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds)
} catch {
Logger.error(error)
}
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount)
let matches = (0..<matchCount).map { //0 is final match
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0)
let round = rounds[roundIndex]
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat, name: round.roundTitle(initialMode: true))
//initial mode let the roundTitle give a name without considering the playable match
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches)
} catch {
Logger.error(error)
}
loserRounds().forEach { round in
round.buildLoserBracket()
}
}
var parentRound: Round? {
guard let parent = parent else { return nil }
return self.tournamentStore.rounds.findById(parent)
}
func nextRoundsDisableMatches() -> Int {
if parent == nil, index > 0 {
return tournamentObject()?.rounds().suffix(index).flatMap { $0.disabledMatches() }.count ?? 0
} else {
return 0
}
}
func updateMatchFormat(_ updatedMatchFormat: MatchFormat, checkIfPossible: Bool, andLoserBracket: Bool) {
if updatedMatchFormat.weight < self.matchFormat.weight {
updateMatchFormatAndAllMatches(updatedMatchFormat)
if andLoserBracket {
loserRoundsAndChildren().forEach { round in
round.updateMatchFormat(updatedMatchFormat, checkIfPossible: checkIfPossible, andLoserBracket: true)
}
}
}
}
func updateMatchFormatAndAllMatches(_ updatedMatchFormat: MatchFormat) {
self.matchFormat = updatedMatchFormat
self.updateMatchFormatOfAllMatches(updatedMatchFormat)
}
func updateMatchFormatOfAllMatches(_ updatedMatchFormat: MatchFormat) {
let playedMatches = _matches()
playedMatches.forEach { match in
match.matchFormat = updatedMatchFormat
}
do {
try self.tournamentStore.matches.addOrUpdate(contentOfs: playedMatches)
} catch {
Logger.error(error)
}
}
override func deleteDependencies() throws {
let matches = self._matches()
for match in matches {
try match.deleteDependencies()
}
self.tournamentStore.matches.deleteDependencies(matches)
let loserRounds = self.loserRounds()
for round in loserRounds {
try round.deleteDependencies()
}
self.tournamentStore.rounds.deleteDependencies(loserRounds)
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"
case _index = "index"
case _parent = "parent"
case _format = "format"
case _startDate = "startDate"
case _groupStageLoserBracket = "groupStageLoserBracket"
case _loserBracketMode = "loserBracketMode"
}
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: ._id)
tournament = try container.decode(String.self, forKey: ._tournament)
index = try container.decode(Int.self, forKey: ._index)
parent = try container.decodeIfPresent(String.self, forKey: ._parent)
format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format)
startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate)
groupStageLoserBracket = try container.decodeIfPresent(Bool.self, forKey: ._groupStageLoserBracket) ?? false
loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(tournament, forKey: ._tournament)
try container.encode(index, forKey: ._index)
try container.encode(groupStageLoserBracket, forKey: ._groupStageLoserBracket)
try container.encode(loserBracketMode, forKey: ._loserBracketMode)
try container.encode(parent, forKey: ._parent)
try container.encode(format, forKey: ._format)
try container.encode(startDate, forKey: ._startDate)
}
func insertOnServer() {
self.tournamentStore.rounds.writeChangeAndInsertOnServer(instance: self)
for match in self._matches() {
match.insertOnServer()
}
}
}
extension Round: Selectable, Equatable {
static func == (lhs: Round, rhs: Round) -> Bool {
lhs.id == rhs.id
}
func selectionLabel(index: Int) -> String {
if let parentRound {
return "Tour #\(parentRound.loserRounds().count - index)"
} else {
return roundTitle(.short)
}
}
func badgeValue() -> Int? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func badgeValue round of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if let parentRound {
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count
} else {
return playedMatches().filter({ $0.isRunning() }).count
}
}
func badgeValueColor() -> Color? {
return nil
}
func badgeImage() -> Badge? {
#if DEBUG //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func badgeImage of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
return hasEnded() ? .checkmark : nil
}
}
enum LoserBracketMode: Int, CaseIterable, Identifiable, Codable {
var id: Int { self.rawValue }
case automatic
case manual
func localizedLoserBracketMode() -> String {
switch self {
case .automatic:
"Automatique"
case .manual:
"Manuelle"
}
}
func localizedLoserBracketModeDescription() -> String {
switch self {
case .automatic:
"Les perdants du tableau principal sont placés à leur place prévue."
case .manual:
"Aucun placement automatique n'est fait. Vous devez choisir les perdants qui se jouent."
}
}
}

@ -0,0 +1,852 @@
//
// TeamRegistration.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
import SwiftUI
@Observable
final class TeamRegistration: ModelObject, Storable {
static func resourceName() -> String { "team-registrations" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = []
var id: String = Store.randomId()
var tournament: String
var groupStage: String?
var registrationDate: Date?
var callDate: Date?
var bracketPosition: Int?
var groupStagePosition: Int?
var comment: String?
var source: String?
var sourceValue: String?
var logo: String?
var name: String?
var walkOut: Bool = false
var wildCardBracket: Bool = false
var wildCardGroupStage: Bool = false
var weight: Int = 0
var lockedWeight: Int?
var confirmationDate: Date?
var qualified: Bool = false
var finalRanking: Int?
var pointsEarned: Int?
var unregistered: Bool = false
var unregistrationDate: Date? = nil
var clubCode: String?
var registratonMail: String?
var clubName: String?
func hasUnregistered() -> Bool {
unregistered
}
func hasRegisteredOnline() -> Bool {
players().anySatisfy({ $0.source == .onlineRegistration })
}
func isOutOfTournament() -> Bool {
walkOut || unregistered
}
init(tournament: String, groupStage: String? = nil, registrationDate: Date? = nil, callDate: Date? = nil, bracketPosition: Int? = nil, groupStagePosition: Int? = nil, comment: String? = nil, source: String? = nil, sourceValue: String? = nil, logo: String? = nil, name: String? = nil, walkOut: Bool = false, wildCardBracket: Bool = false, wildCardGroupStage: Bool = false, weight: Int = 0, lockedWeight: Int? = nil, confirmationDate: Date? = nil, qualified: Bool = false, finalRanking: Int? = nil, pointsEarned: Int? = nil, unregistered: Bool = false, unregistrationDate: Date? = nil) {
self.tournament = tournament
self.groupStage = groupStage
self.registrationDate = registrationDate ?? Date()
self.callDate = callDate
self.bracketPosition = bracketPosition
self.groupStagePosition = groupStagePosition
self.comment = comment
self.source = source
self.sourceValue = sourceValue
self.logo = logo
self.name = name
self.walkOut = walkOut
self.wildCardBracket = wildCardBracket
self.wildCardGroupStage = wildCardGroupStage
self.weight = weight
self.lockedWeight = lockedWeight
self.confirmationDate = confirmationDate
self.qualified = qualified
self.finalRanking = finalRanking
self.pointsEarned = pointsEarned
self.unregistered = unregistered
self.unregistrationDate = unregistrationDate
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
// MARK: - Computed dependencies
func unsortedPlayers() -> [PlayerRegistration] {
return self.tournamentStore.playerRegistrations.filter { $0.teamRegistration == self.id && $0.coach == false && $0.captain == false }
}
// MARK: -
func deleteTeamScores() {
let ts = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id })
do {
try self.tournamentStore.teamScores.delete(contentOfs: ts)
} catch {
Logger.error(error)
}
}
override func deleteDependencies() throws {
let unsortedPlayers = unsortedPlayers()
for player in unsortedPlayers {
try player.deleteDependencies()
}
self.tournamentStore.playerRegistrations.deleteDependencies(unsortedPlayers)
let teamScores = teamScores()
for teamScore in teamScores {
try teamScore.deleteDependencies()
}
self.tournamentStore.teamScores.deleteDependencies(teamScores)
}
func hasArrived(isHere: Bool = false) {
let unsortedPlayers = unsortedPlayers()
unsortedPlayers.forEach({ $0.hasArrived = !isHere })
do {
try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: unsortedPlayers)
} catch {
Logger.error(error)
}
}
func isHere() -> Bool {
let unsortedPlayers = unsortedPlayers()
return unsortedPlayers.allSatisfy({ $0.hasArrived })
}
func isSeedable() -> Bool {
bracketPosition == nil && groupStage == nil
}
func setSeedPosition(inSpot match: Match, slot: TeamPosition?, opposingSeeding: Bool) {
var teamPosition : TeamPosition {
if let slot {
return slot
} else {
let matchIndex = match.index
let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex)
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound)
let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2)
var teamPosition = slot ?? (isUpper ? .one : .two)
if opposingSeeding {
teamPosition = slot ?? (isUpper ? .two : .one)
}
return teamPosition
}
}
let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: teamPosition)
tournamentObject()?.resetTeamScores(in: bracketPosition)
self.bracketPosition = seedPosition
if groupStagePosition != nil && qualified == false {
qualified = true
}
if let tournament = tournamentObject() {
if let index = index(in: tournament.selectedSortedTeams()) {
let drawLog = DrawLog(tournament: tournament.id, drawSeed: index, drawMatchIndex: match.index, drawTeamPosition: teamPosition, drawType: .seed)
do {
try tournamentStore.drawLogs.addOrUpdate(instance: drawLog)
} catch {
Logger.error(error)
}
}
tournament.updateTeamScores(in: bracketPosition)
}
}
func expectedSummonDate() -> Date? {
if let groupStageStartDate = groupStageObject()?.startDate {
return groupStageStartDate
} else if let roundMatchStartDate = initialMatch()?.startDate {
return roundMatchStartDate
}
return nil
}
var initialWeight: Int {
return lockedWeight ?? weight
}
func called() -> Bool {
return callDate != nil
}
func confirmed() -> Bool {
return confirmationDate != nil
}
func getPhoneNumbers() -> [String] {
return players().compactMap { $0.phoneNumber }.filter({ $0.isMobileNumber() })
}
func getMail() -> [String] {
let mails = players().compactMap({ $0.email })
return mails
}
func isImported() -> Bool {
return unsortedPlayers().allSatisfy({ $0.isImported() })
}
func isWildCard() -> Bool {
return wildCardBracket || wildCardGroupStage
}
func isPlaying() -> Bool {
return currentMatch() != nil
}
func currentMatch() -> Match? {
return teamScores().compactMap { $0.matchObject() }.first(where: { $0.isRunning() })
}
func teamScores() -> [TeamScore] {
return self.tournamentStore.teamScores.filter({ $0.teamRegistration == id })
}
func wins() -> [Match] {
return self.tournamentStore.matches.filter({ $0.winningTeamId == id })
}
func loses() -> [Match] {
return self.tournamentStore.matches.filter({ $0.losingTeamId == id })
}
func matches() -> [Match] {
return self.tournamentStore.matches.filter({ $0.losingTeamId == id || $0.winningTeamId == id })
}
var tournamentCategory: TournamentCategory {
tournamentObject()?.tournamentCategory ?? .men
}
@objc
var canonicalName: String {
players().map { $0.canonicalName }.joined(separator: " ")
}
func hasMemberOfClub(_ codeClubOrClubName: String?) -> Bool {
guard let codeClubOrClubName else { return true }
return unsortedPlayers().anySatisfy({
$0.clubName?.contains(codeClubOrClubName) == true || $0.clubName?.contains(codeClubOrClubName) == true
})
}
func updateWeight(inTournamentCategory tournamentCategory: TournamentCategory) {
self.setWeight(from: self.players(), inTournamentCategory: tournamentCategory)
}
func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String {
if let name { return name }
return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ")
}
func teamLabelRanked(displayRank: Bool, displayTeamName: Bool) -> String {
[displayTeamName ? name : nil, displayRank ? seedIndex() : nil, displayTeamName ? (name == nil ? teamLabel() : name) : teamLabel()].compactMap({ $0 }).joined(separator: " ")
}
func seedIndex() -> String? {
guard let tournament = tournamentObject() else { return nil }
guard let index = index(in: tournament.selectedSortedTeams()) else { return nil }
return "(\(index + 1))"
}
func index(in teams: [TeamRegistration]) -> Int? {
return teams.firstIndex(where: { $0.id == id })
}
func formattedSeed(in teams: [TeamRegistration]) -> String {
if let index = index(in: teams) {
return "#\(index + 1)"
} else {
return "###"
}
}
func contains(_ searchField: String) -> Bool {
return unsortedPlayers().anySatisfy({ $0.contains(searchField) }) || self.name?.localizedCaseInsensitiveContains(searchField) == true
}
func containsExactlyPlayerLicenses(_ playerLicenses: [String?]) -> Bool {
let arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion })
let ids : Set<String> = Set<String>(arrayOfIds.sorted())
let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted())
if ids.isEmpty || searchedIds.isEmpty { return false }
return ids.hashValue == searchedIds.hashValue
}
func includes(players: [PlayerRegistration]) -> Bool {
let unsortedPlayers = unsortedPlayers()
guard players.count == unsortedPlayers.count else { return false }
return players.allSatisfy { player in
unsortedPlayers.anySatisfy { _player in
_player.isSameAs(player)
}
}
}
func includes(player: PlayerRegistration) -> Bool {
return unsortedPlayers().anySatisfy { _player in
_player.isSameAs(player)
}
}
func canPlay() -> Bool {
return matches().isEmpty == false || players().allSatisfy({ $0.hasPaid() || $0.hasArrived })
}
func availableForSeedPick() -> Bool {
return groupStage == nil && bracketPosition == nil
}
func inGroupStage() -> Bool {
return groupStagePosition != nil
}
func inRound() -> Bool {
return bracketPosition != nil
}
func positionLabel() -> String? {
if groupStagePosition != nil { return "Poule" }
if let initialRound = initialRound() {
return initialRound.roundTitle()
} else {
return nil
}
}
func initialRoundColor() -> Color? {
if unregistered { return Color.black }
if walkOut { return Color.logoRed }
if groupStagePosition != nil { return Color.blue }
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] {
return Color(uiColor: .init(fromHex: colorHex))
} else {
return nil
}
}
func resetGroupeStagePosition() {
if let groupStage {
let matches = self.tournamentStore.matches.filter({ $0.groupStage == groupStage }).map { $0.id }
let teamScores = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id && matches.contains($0.match) })
do {
try tournamentStore.teamScores.delete(contentOfs: teamScores)
} catch {
Logger.error(error)
}
}
//groupStageObject()?._matches().forEach({ $0.updateTeamScores() })
groupStage = nil
groupStagePosition = nil
}
func resetBracketPosition() {
let matches = self.tournamentStore.matches.filter({ $0.groupStage == nil }).map { $0.id }
let teamScores = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id && matches.contains($0.match) })
do {
try tournamentStore.teamScores.delete(contentOfs: teamScores)
} catch {
Logger.error(error)
}
self.bracketPosition = nil
}
func resetPositions() {
resetGroupeStagePosition()
resetBracketPosition()
}
func pasteData(_ exportFormat: ExportFormat = .rawText, _ index: Int = 0, allPlayers: [(String, String)] = []) -> String {
switch exportFormat {
case .rawText:
return [playersPasteData(exportFormat), formattedInscriptionDate(exportFormat), name].compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator())
case .csv:
return [index.formatted(), playersPasteData(exportFormat), isWildCard() ? "WC" : weight.formatted()].joined(separator: exportFormat.separator())
case .championship:
var baseValue: [String] = [
formattedInscriptionDate(exportFormat) ?? "",
alertCountFormatted(teamIndex: index, allPlayers: allPlayers),
alertDescription(teamIndex: index, allPlayers: allPlayers),
teamWeightFormatted(),
jokerWeightFormatted(),
playerCountFormatted(),
nveqCountFormatted(),
unrankedCountFormatted(),
registratonMail ?? "",
clubCode ?? "",
clubName ?? "",
tournamentObject()?.tournamentCategory.importingRawValue.capitalized ?? "",
teamNameLabel(),
]
// if let captain = captain() {
// baseValue.append(contentsOf: captain.pasteData())
// } else {
// baseValue.append("")
// baseValue.append("")
// baseValue.append("")
// }
// if let captain = coach() {
// baseValue.append(contentsOf: captain.coach())
// } else {
// baseValue.append("")
// baseValue.append("")
// baseValue.append("")
// }
var final = baseValue.joined(separator: exportFormat.separator())
players().forEach { pr in
final.append(exportFormat.separator() + pr.pasteData(exportFormat))
}
return final
}
}
func teamWeightFormatted() -> String {
let value = players().prefix(6).map({ $0.computedRank }).reduce(0,+)
return "\(value)"
}
func unrankedCountFormatted() -> String {
players().filter({ $0.isUnranked() && $0.source == nil }).count.formatted()
}
func alertDescription(teamIndex: Int, allPlayers: [(String, String)]) -> String {
let multiLineString = championshipAlerts(teamIndex: teamIndex, tournament: tournamentObject()!, allPlayers: allPlayers).compactMap({ $0.errorDescription }).joined(separator: "\n")
let escapedString = "\"\(multiLineString.replacingOccurrences(of: "\"", with: "\"\""))\""
return escapedString
}
func championshipAlerts(teamIndex: Int, tournament: Tournament, allPlayers: [(String, String)]) -> [ChampionshipAlert] {
var alerts = [ChampionshipAlert]()
if clubCode?.isValidCodeClub(62) == false {
alerts.append(.clubCodeInvalid(self))
}
let players = players()
if teamIndex <= 24, players.filter({ $0.isNveq }).count > 2 {
alerts.append(.tooManyNVEQ(self))
}
// if teamIndex <= 24, players.count > 8 {
// alerts.append(.tooManyPlayers(self))
//
// }
players.forEach { pr in
alerts.append(contentsOf: pr.championshipAlerts(tournament: tournament, allPlayers: allPlayers, forRegional: teamIndex <= 24))
}
return alerts
}
func jokerWeightFormatted() -> String {
if let joker = players()[safe:5] {
return "\(joker.computedRank)"
} else {
return ""
}
}
func alertCountFormatted(teamIndex: Int, allPlayers: [(String, String)]) -> String {
let championshipAlertsCount = championshipAlerts(teamIndex: teamIndex, tournament: tournamentObject()!, allPlayers: allPlayers).count
return championshipAlertsCount.formatted()
}
func nveqCountFormatted() -> String {
players().filter({ $0.isNveq }).count.formatted()
}
func playerCountFormatted() -> String {
let unsortedPlayersCount = unsortedPlayers().count
return unsortedPlayersCount.formatted()
}
var computedRegistrationDate: Date {
return registrationDate ?? .distantFuture
}
func formattedInscriptionDate(_ exportFormat: ExportFormat = .rawText) -> String? {
switch exportFormat {
case .rawText:
if let registrationDate {
return "Inscrit le " + registrationDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
case .csv:
if let registrationDate {
return registrationDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
case .championship:
if let registrationDate {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return dateFormatter.string(from: registrationDate)
} else {
return nil
}
}
}
func formattedSummonDate(_ exportFormat: ExportFormat = .rawText) -> String? {
switch exportFormat {
case .rawText:
if let callDate {
return "Convoqué le " + callDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
case .csv, .championship:
if let callDate {
return callDate.formatted(.dateTime.weekday().day().month().hour().minute())
} else {
return nil
}
}
}
func playersPasteData(_ exportFormat: ExportFormat = .rawText) -> String {
switch exportFormat {
case .rawText:
return players().map { $0.pasteData(exportFormat) }.joined(separator: exportFormat.newLineSeparator())
case .csv:
return players().map { [$0.pasteData(exportFormat), isWildCard() ? "WC" : $0.computedRank.formatted() ].joined(separator: exportFormat.separator()) }.joined(separator: exportFormat.separator())
case .championship:
return players().map { $0.pasteData(exportFormat) }.joined(separator: exportFormat.separator())
}
}
func updatePlayers(_ players: Set<PlayerRegistration>, inTournamentCategory tournamentCategory: TournamentCategory) {
let previousPlayers = Set(unsortedPlayers())
let playersToRemove = previousPlayers.subtracting(players)
do {
try self.tournamentStore.playerRegistrations.delete(contentOfs: playersToRemove)
} catch {
Logger.error(error)
}
setWeight(from: Array(players), inTournamentCategory: tournamentCategory)
players.forEach { player in
player.teamRegistration = id
}
}
typealias TeamRange = (left: TeamRegistration?, right: TeamRegistration?)
func replacementRange() -> TeamRange? {
guard let tournamentObject = tournamentObject() else { return nil }
guard let index = tournamentObject.indexOf(team: self) else { return nil }
let selectedSortedTeams = tournamentObject.selectedSortedTeams()
let left = selectedSortedTeams[safe: index - 1]
let right = selectedSortedTeams[safe: index + 1]
return (left: left, right: right)
}
func replacementRangeExtended() -> TeamRange? {
guard let tournamentObject = tournamentObject() else { return nil }
guard let groupStagePosition else { return nil }
let selectedSortedTeams = tournamentObject.selectedSortedTeams()
var left: TeamRegistration? = nil
if groupStagePosition == 0 {
left = tournamentObject.seeds().last
} else {
let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition - 1 }).sorted(by: \.weight)
left = previousHat.last
}
var right: TeamRegistration? = nil
if groupStagePosition == tournamentObject.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 AreInIncreasingOrder = (PlayerRegistration, PlayerRegistration) -> Bool
func players() -> [PlayerRegistration] {
self.unsortedPlayers().sorted { (lhs, rhs) in
let predicates: [AreInIncreasingOrder] = [
{ $0.sex?.rawValue ?? 0 < $1.sex?.rawValue ?? 0 },
{ $0.rank ?? Int.max < $1.rank ?? Int.max },
{ $0.lastName < $1.lastName},
{ $0.firstName < $1.firstName }
]
for predicate in predicates {
if !predicate(lhs, rhs) && !predicate(rhs, lhs) {
continue
}
return predicate(lhs, rhs)
}
return false
}
}
func coaches() -> [PlayerRegistration] {
tournamentStore.playerRegistrations.filter({ $0.coach })
}
func setWeight(from players: [PlayerRegistration], inTournamentCategory tournamentCategory: TournamentCategory) {
let significantPlayerCount = significantPlayerCount()
weight = (players.prefix(significantPlayerCount).map { $0.computedRank } + missingPlayerType(inTournamentCategory: tournamentCategory).map { unrankValue(for: $0 == 1 ? true : false ) }).prefix(significantPlayerCount).reduce(0,+)
}
func significantPlayerCount() -> Int {
return tournamentObject()?.significantPlayerCount() ?? 2
}
func missingPlayerType(inTournamentCategory tournamentCategory: TournamentCategory) -> [Int] {
let players = unsortedPlayers()
if players.count >= 2 { return [] }
let s = players.compactMap { $0.sex?.rawValue }
var missing = tournamentCategory.mandatoryPlayerType()
s.forEach { i in
if let index = missing.firstIndex(of: i) {
missing.remove(at: index)
}
}
return missing
}
func unrankValue(for malePlayer: Bool) -> Int {
return tournamentObject()?.unrankValue(for: malePlayer) ?? 70_000
}
func groupStageObject() -> GroupStage? {
guard let groupStage else { return nil }
return self.tournamentStore.groupStages.findById(groupStage)
}
func initialRound() -> Round? {
guard let bracketPosition else { return nil }
let roundIndex = RoundRule.roundIndex(fromMatchIndex: bracketPosition / 2)
return self.tournamentStore.rounds.first(where: { $0.index == roundIndex })
}
func initialMatch() -> Match? {
guard let bracketPosition else { return nil }
guard let initialRoundObject = initialRound() else { return nil }
return self.tournamentStore.matches.first(where: { $0.round == initialRoundObject.id && $0.index == bracketPosition / 2 })
}
func toggleSummonConfirmation() {
if confirmationDate == nil { confirmationDate = Date() }
else { confirmationDate = nil }
}
func didConfirmSummon() -> Bool {
confirmationDate != nil
}
func tournamentObject() -> Tournament? {
return Store.main.findById(tournament)
}
func groupStagePositionAtStep(_ step: Int) -> Int? {
guard let groupStagePosition else { return nil }
if step == 0 {
return groupStagePosition
} else if let groupStageObject = groupStageObject(), groupStageObject.hasEnded() {
return groupStageObject.index
}
return nil
}
func wildcardLabel() -> String? {
if isWildCard() {
let wildcardLabel: String = ["wildcard", (wildCardBracket ? "tableau" : "poule")].joined(separator: " ")
return wildcardLabel
} else {
return nil
}
}
var _cachedRestingTime: (Bool, Date?)?
func restingTime() -> Date? {
if let _cachedRestingTime { return _cachedRestingTime.1 }
let restingTime = matches().filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).last?.endDate
_cachedRestingTime = (true, restingTime)
return restingTime
}
func resetRestingTime() {
_cachedRestingTime = nil
}
var restingTimeForSorting: Date {
restingTime()!
}
func teamNameLabel() -> String {
if let name, name.isEmpty == false {
return name
} else {
return "Toute l'équipe"
}
}
func isDifferentPosition(_ drawMatchIndex: Int?) -> Bool {
if let bracketPosition, let drawMatchIndex {
return drawMatchIndex != bracketPosition
} else if let bracketPosition {
return true
} else if let drawMatchIndex {
return true
}
return false
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _tournament = "tournament"
case _groupStage = "groupStage"
case _registrationDate = "registrationDate"
case _callDate = "callDate"
case _bracketPosition = "bracketPosition"
case _groupStagePosition = "groupStagePosition"
case _comment = "comment"
case _source = "source"
case _sourceValue = "sourceValue"
case _logo = "logo"
case _name = "name"
case _wildCardBracket = "wildCardBracket"
case _wildCardGroupStage = "wildCardGroupStage"
case _weight = "weight"
case _walkOut = "walkOut"
case _lockedWeight = "lockedWeight"
case _confirmationDate = "confirmationDate"
case _qualified = "qualified"
case _finalRanking = "finalRanking"
case _pointsEarned = "pointsEarned"
case _unregistered = "unregistered"
case _unregistrationDate = "unregistrationDate"
case _clubCode = "clubCode"
case _clubName = "clubName"
case _registratonMail = "registratonMail"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Non-optional properties
id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId()
tournament = try container.decode(String.self, forKey: ._tournament)
walkOut = try container.decodeIfPresent(Bool.self, forKey: ._walkOut) ?? false
wildCardBracket = try container.decodeIfPresent(Bool.self, forKey: ._wildCardBracket) ?? false
wildCardGroupStage = try container.decodeIfPresent(Bool.self, forKey: ._wildCardGroupStage) ?? false
weight = try container.decodeIfPresent(Int.self, forKey: ._weight) ?? 0
qualified = try container.decodeIfPresent(Bool.self, forKey: ._qualified) ?? false
unregistered = try container.decodeIfPresent(Bool.self, forKey: ._unregistered) ?? false
// Optional properties
groupStage = try container.decodeIfPresent(String.self, forKey: ._groupStage)
registrationDate = try container.decodeIfPresent(Date.self, forKey: ._registrationDate)
callDate = try container.decodeIfPresent(Date.self, forKey: ._callDate)
bracketPosition = try container.decodeIfPresent(Int.self, forKey: ._bracketPosition)
groupStagePosition = try container.decodeIfPresent(Int.self, forKey: ._groupStagePosition)
comment = try container.decodeIfPresent(String.self, forKey: ._comment)
source = try container.decodeIfPresent(String.self, forKey: ._source)
sourceValue = try container.decodeIfPresent(String.self, forKey: ._sourceValue)
logo = try container.decodeIfPresent(String.self, forKey: ._logo)
name = try container.decodeIfPresent(String.self, forKey: ._name)
lockedWeight = try container.decodeIfPresent(Int.self, forKey: ._lockedWeight)
confirmationDate = try container.decodeIfPresent(Date.self, forKey: ._confirmationDate)
finalRanking = try container.decodeIfPresent(Int.self, forKey: ._finalRanking)
pointsEarned = try container.decodeIfPresent(Int.self, forKey: ._pointsEarned)
unregistrationDate = try container.decodeIfPresent(Date.self, forKey: ._unregistrationDate)
clubCode = try container.decodeIfPresent(String.self, forKey: ._clubCode)
clubName = try container.decodeIfPresent(String.self, forKey: ._clubName)
registratonMail = try container.decodeIfPresent(String.self, forKey: ._registratonMail)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(tournament, forKey: ._tournament)
try container.encode(groupStage, forKey: ._groupStage)
try container.encode(registrationDate, forKey: ._registrationDate)
try container.encode(callDate, forKey: ._callDate)
try container.encode(bracketPosition, forKey: ._bracketPosition)
try container.encode(groupStagePosition, forKey: ._groupStagePosition)
try container.encode(comment, forKey: ._comment)
try container.encode(source, forKey: ._source)
try container.encode(sourceValue, forKey: ._sourceValue)
try container.encode(logo, forKey: ._logo)
try container.encode(name, forKey: ._name)
try container.encode(walkOut, forKey: ._walkOut)
try container.encode(wildCardBracket, forKey: ._wildCardBracket)
try container.encode(wildCardGroupStage, forKey: ._wildCardGroupStage)
try container.encode(weight, forKey: ._weight)
try container.encode(lockedWeight, forKey: ._lockedWeight)
try container.encode(confirmationDate, forKey: ._confirmationDate)
try container.encode(qualified, forKey: ._qualified)
try container.encode(finalRanking, forKey: ._finalRanking)
try container.encode(pointsEarned, forKey: ._pointsEarned)
try container.encode(unregistered, forKey: ._unregistered)
try container.encode(unregistrationDate, forKey: ._unregistrationDate)
try container.encode(clubCode, forKey: ._clubCode)
try container.encode(clubName, forKey: ._clubName)
try container.encode(registratonMail, forKey: ._registratonMail)
}
func insertOnServer() {
self.tournamentStore.teamRegistrations.writeChangeAndInsertOnServer(instance: self)
for playerRegistration in self.unsortedPlayers() {
playerRegistration.insertOnServer()
}
}
}
extension TeamRegistration: Hashable {
static func == (lhs: TeamRegistration, rhs: TeamRegistration) -> Bool {
lhs.id == rhs.id
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
enum TeamDataSource: Int, Codable {
case beachPadel
}

@ -0,0 +1,98 @@
//
// TeamScore.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
@Observable
final class TeamScore: ModelObject, Storable {
static func resourceName() -> String { "team-scores" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = ["match"]
var id: String = Store.randomId()
var match: String
var teamRegistration: String?
//var playerRegistrations: [String] = []
var score: String?
var walkOut: Int?
var luckyLoser: Int?
init(match: String, teamRegistration: String? = nil, score: String? = nil, walkOut: Int? = nil, luckyLoser: Int? = nil) {
self.match = match
self.teamRegistration = teamRegistration
// self.playerRegistrations = playerRegistrations
self.score = score
self.walkOut = walkOut
self.luckyLoser = luckyLoser
}
init(match: String, team: TeamRegistration?) {
self.match = match
if let team {
self.teamRegistration = team.id
//self.playerRegistrations = team.players().map { $0.id }
}
self.score = nil
self.walkOut = nil
self.luckyLoser = nil
}
var tournamentStore: TournamentStore {
if let store = self.store as? TournamentStore {
return store
}
fatalError("missing store for \(String(describing: type(of: self)))")
}
// MARK: - Computed dependencies
func matchObject() -> Match? {
return self.tournamentStore.matches.findById(self.match)
}
var team: TeamRegistration? {
guard let teamRegistration else {
return nil
}
return self.tournamentStore.teamRegistrations.findById(teamRegistration)
}
// MARK: -
func isWalkOut() -> Bool {
return walkOut != nil
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _match = "match"
case _teamRegistration = "teamRegistration"
//case _playerRegistrations = "playerRegistrations"
case _score = "score"
case _walkOut = "walkOut"
case _luckyLoser = "luckyLoser"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(match, forKey: ._match)
try container.encode(teamRegistration, forKey: ._teamRegistration)
try container.encode(score, forKey: ._score)
try container.encode(walkOut, forKey: ._walkOut)
try container.encode(luckyLoser, forKey: ._luckyLoser)
}
func insertOnServer() {
self.tournamentStore.teamScores.writeChangeAndInsertOnServer(instance: self)
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,61 @@
//
// TournamentStore.swift
// PadelClub
//
// Created by Laurent Morvillier on 26/06/2024.
//
import Foundation
import LeStorage
import SwiftUI
class TournamentStore: Store, ObservableObject {
static func instance(tournamentId: String) -> TournamentStore {
// if StoreCenter.main.userId == nil {
// fatalError("cant request store without id")
// }
return StoreCenter.main.store(identifier: tournamentId, parameter: "tournament")
}
fileprivate(set) var groupStages: StoredCollection<GroupStage> = StoredCollection.placeholder()
fileprivate(set) var matches: StoredCollection<Match> = StoredCollection.placeholder()
fileprivate(set) var teamRegistrations: StoredCollection<TeamRegistration> = StoredCollection.placeholder()
fileprivate(set) var playerRegistrations: StoredCollection<PlayerRegistration> = StoredCollection.placeholder()
fileprivate(set) var rounds: StoredCollection<Round> = StoredCollection.placeholder()
fileprivate(set) var teamScores: StoredCollection<TeamScore> = StoredCollection.placeholder()
fileprivate(set) var matchSchedulers: StoredCollection<MatchScheduler> = StoredCollection.placeholder()
fileprivate(set) var drawLogs: StoredCollection<DrawLog> = StoredCollection.placeholder()
convenience init(tournament: Tournament) {
self.init(identifier: tournament.id, parameter: "tournament")
}
required init(identifier: String, parameter: String) {
super.init(identifier: identifier, parameter: parameter)
var synchronized: Bool = true
let indexed: Bool = true
#if DEBUG
if let sync = PListReader.readBool(plist: "local", key: "synchronized") {
synchronized = sync
}
#endif
self.groupStages = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.rounds = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.teamRegistrations = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.playerRegistrations = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.matches = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.teamScores = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.matchSchedulers = self.registerCollection(synchronized: false, indexed: indexed)
self.drawLogs = self.registerCollection(synchronized: synchronized, indexed: indexed)
self.loadCollectionsFromServerIfNoFile()
}
}

@ -0,0 +1,250 @@
//
// User.swift
// PadelClub
//
// Created by Laurent Morvillier on 21/02/2024.
//
import Foundation
import LeStorage
enum UserRight: Int, Codable {
case none = 0
case edition = 1
case creation = 2
}
@Observable
class User: ModelObject, UserBase, Storable {
static func resourceName() -> String { "users" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [.post] }
static func filterByStoreIdentifier() -> Bool { return false }
static var relationshipNames: [String] = []
public var id: String = Store.randomId()
public var username: String
public var email: String
var clubs: [String] = []
var umpireCode: String?
var licenceId: String?
var firstName: String
var lastName: String
var phone: String?
var country: String?
var summonsMessageBody : String? = nil
var summonsMessageSignature: String? = nil
var summonsAvailablePaymentMethods: String? = nil
var summonsDisplayFormat: Bool = false
var summonsDisplayEntryFee: Bool = false
var summonsUseFullCustomMessage: Bool = false
var matchFormatsDefaultDuration: [MatchFormat: Int]? = nil
var bracketMatchFormatPreference: MatchFormat?
var groupStageMatchFormatPreference: MatchFormat?
var loserBracketMatchFormatPreference: MatchFormat?
var loserBracketMode: LoserBracketMode = .automatic
var deviceId: String?
init(username: String, email: String, firstName: String, lastName: String, phone: String?, country: String?, loserBracketMode: LoserBracketMode = .automatic) {
self.username = username
self.firstName = firstName
self.lastName = lastName
self.email = email
self.phone = phone
self.country = country
self.loserBracketMode = loserBracketMode
}
public func uuid() throws -> UUID {
if let uuid = UUID(uuidString: self.id) {
return uuid
}
throw UUIDError.cantConvertString(string: self.id)
}
func currentPlayerData() -> ImportedPlayer? {
guard let licenceId else { return nil }
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "license == %@", licenceId)
fetchRequest.predicate = predicate
return try? federalContext.fetch(fetchRequest).first
}
func defaultSignature() -> String {
return "Sportivement,\n\(firstName) \(lastName), votre JAP."
}
func fullName() -> String? {
guard firstName.isEmpty == false && lastName.isEmpty == false else {
return nil
}
return "\(firstName) \(lastName)"
}
func hasTenupClubs() -> Bool {
self.clubsObjects().filter({ $0.code != nil }).isEmpty == false
}
func hasFavoriteClubsAndCreatedClubs() -> Bool {
clubsObjects(includeCreated: true).isEmpty == false
}
func setUserClub(_ userClub: Club) {
self.clubs.insert(userClub.id, at: 0)
}
func clubsObjects(includeCreated: Bool = false) -> [Club] {
return DataStore.shared.clubs.filter({ (includeCreated && $0.creator == id) || clubs.contains($0.id) })
}
func createdClubsObjectsNotFavorite() -> [Club] {
return DataStore.shared.clubs.filter({ ($0.creator == id) && clubs.contains($0.id) == false })
}
func saveMatchFormatsDefaultDuration(_ matchFormat: MatchFormat, estimatedDuration: Int) {
if estimatedDuration == matchFormat.defaultEstimatedDuration {
matchFormatsDefaultDuration?.removeValue(forKey: matchFormat)
} else {
matchFormatsDefaultDuration = matchFormatsDefaultDuration ?? [MatchFormat: Int]()
matchFormatsDefaultDuration?[matchFormat] = estimatedDuration
}
}
func addClub(_ club: Club) {
if !self.clubs.contains(where: { $0.id == club.id }) {
self.clubs.append(club.id)
}
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _username = "username"
case _email = "email"
case _clubs = "clubs"
case _umpireCode = "umpireCode"
case _licenceId = "licenceId"
case _firstName = "firstName"
case _lastName = "lastName"
case _phone = "phone"
case _country = "country"
case _summonsMessageBody = "summonsMessageBody"
case _summonsMessageSignature = "summonsMessageSignature"
case _summonsAvailablePaymentMethods = "summonsAvailablePaymentMethods"
case _summonsDisplayFormat = "summonsDisplayFormat"
case _summonsDisplayEntryFee = "summonsDisplayEntryFee"
case _summonsUseFullCustomMessage = "summonsUseFullCustomMessage"
case _matchFormatsDefaultDuration = "matchFormatsDefaultDuration"
case _bracketMatchFormatPreference = "bracketMatchFormatPreference"
case _groupStageMatchFormatPreference = "groupStageMatchFormatPreference"
case _loserBracketMatchFormatPreference = "loserBracketMatchFormatPreference"
case _deviceId = "deviceId"
case _loserBracketMode = "loserBracketMode"
}
public required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
// Required properties
id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId()
username = try container.decode(String.self, forKey: ._username)
email = try container.decode(String.self, forKey: ._email)
firstName = try container.decode(String.self, forKey: ._firstName)
lastName = try container.decode(String.self, forKey: ._lastName)
// Optional properties
clubs = try container.decodeIfPresent([String].self, forKey: ._clubs) ?? []
umpireCode = try container.decodeIfPresent(String.self, forKey: ._umpireCode)
licenceId = try container.decodeIfPresent(String.self, forKey: ._licenceId)
phone = try container.decodeIfPresent(String.self, forKey: ._phone)
country = try container.decodeIfPresent(String.self, forKey: ._country)
// Summons-related properties
summonsMessageBody = try container.decodeIfPresent(String.self, forKey: ._summonsMessageBody)
summonsMessageSignature = try container.decodeIfPresent(String.self, forKey: ._summonsMessageSignature)
summonsAvailablePaymentMethods = try container.decodeIfPresent(String.self, forKey: ._summonsAvailablePaymentMethods)
summonsDisplayFormat = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayFormat) ?? false
summonsDisplayEntryFee = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayEntryFee) ?? false
summonsUseFullCustomMessage = try container.decodeIfPresent(Bool.self, forKey: ._summonsUseFullCustomMessage) ?? false
// Match-related properties
matchFormatsDefaultDuration = try container.decodeIfPresent([MatchFormat: Int].self, forKey: ._matchFormatsDefaultDuration)
bracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._bracketMatchFormatPreference)
groupStageMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._groupStageMatchFormatPreference)
loserBracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._loserBracketMatchFormatPreference)
loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
try container.encode(username, forKey: ._username)
try container.encode(email, forKey: ._email)
try container.encode(clubs, forKey: ._clubs)
try container.encode(umpireCode, forKey: ._umpireCode)
try container.encode(licenceId, forKey: ._licenceId)
try container.encode(firstName, forKey: ._firstName)
try container.encode(lastName, forKey: ._lastName)
try container.encode(phone, forKey: ._phone)
try container.encode(country, forKey: ._country)
try container.encode(summonsMessageBody, forKey: ._summonsMessageBody)
try container.encode(summonsMessageSignature, forKey: ._summonsMessageSignature)
try container.encode(summonsAvailablePaymentMethods, forKey: ._summonsAvailablePaymentMethods)
try container.encode(summonsDisplayFormat, forKey: ._summonsDisplayFormat)
try container.encode(summonsDisplayEntryFee, forKey: ._summonsDisplayEntryFee)
try container.encode(summonsUseFullCustomMessage, forKey: ._summonsUseFullCustomMessage)
try container.encode(matchFormatsDefaultDuration, forKey: ._matchFormatsDefaultDuration)
try container.encode(bracketMatchFormatPreference, forKey: ._bracketMatchFormatPreference)
try container.encode(groupStageMatchFormatPreference, forKey: ._groupStageMatchFormatPreference)
try container.encode(loserBracketMatchFormatPreference, forKey: ._loserBracketMatchFormatPreference)
try container.encode(deviceId, forKey: ._deviceId)
try container.encode(loserBracketMode, forKey: ._loserBracketMode)
}
static func placeHolder() -> User {
return User(username: "", email: "", firstName: "", lastName: "", phone: nil, country: nil)
}
}
class UserCreationForm: User, UserPasswordBase {
init(user: User, username: String, password: String, firstName: String, lastName: String, email: String, phone: String?, country: String?) {
self.password = password
super.init(username: username, email: email, firstName: firstName, lastName: lastName, phone: phone, country: country)
self.summonsMessageBody = user.summonsMessageBody
self.summonsMessageSignature = user.summonsMessageSignature
self.summonsAvailablePaymentMethods = user.summonsAvailablePaymentMethods
self.summonsDisplayFormat = user.summonsDisplayFormat
self.summonsDisplayEntryFee = user.summonsDisplayEntryFee
self.summonsUseFullCustomMessage = user.summonsUseFullCustomMessage
self.matchFormatsDefaultDuration = user.matchFormatsDefaultDuration
self.bracketMatchFormatPreference = user.bracketMatchFormatPreference
self.groupStageMatchFormatPreference = user.groupStageMatchFormatPreference
self.loserBracketMatchFormatPreference = user.loserBracketMatchFormatPreference
}
required init(from decoder: Decoder) throws {
fatalError("init(from:) has not been implemented")
}
public var password: String
private enum CodingKeys: String, CodingKey {
case password
}
override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.password, forKey: .password)
}
}

@ -0,0 +1,96 @@
//
// Array+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
extension Array {
func chunked(into size: Int) -> [[Element]] {
return stride(from: 0, to: count, by: size).map {
Array(self[$0 ..< Swift.min($0 + size, count)])
}
}
func anySatisfy(_ p: (Element) -> Bool) -> Bool {
return first(where: { p($0) }) != nil
//return !self.allSatisfy { !p($0) }
}
// Check if the number of elements in the sequence is even
var isEven: Bool {
return self.count % 2 == 0
}
// Check if the number of elements in the sequence is odd
var isOdd: Bool {
return self.count % 2 != 0
}
}
extension Array where Element: Equatable {
/// Remove first collection element that is equal to the given `object` or `element`:
mutating func remove(elements: [Element]) {
elements.forEach {
if let index = firstIndex(of: $0) {
remove(at: index)
}
}
}
}
extension Array where Element: CustomStringConvertible {
func customJoined(separator: String, lastSeparator: String) -> String {
switch count {
case 0:
return ""
case 1:
return "\(self[0])"
case 2:
return "\(self[0]) \(lastSeparator) \(self[1])"
default:
let firstPart = dropLast().map { "\($0)" }.joined(separator: ", ")
let lastPart = "\(lastSeparator) \(last!)"
return "\(firstPart) \(lastPart)"
}
}
}
extension Dictionary where Key == Int, Value == [String] {
mutating func setOrAppend(_ element: String?, at key: Int) {
// Check if the element is nil; do nothing if it is
guard let element = element else {
return
}
// Check if the key exists in the dictionary
if var array = self[key] {
// If it exists, append the element to the array
array.append(element)
self[key] = array
} else {
// If it doesn't exist, create a new array with the element
self[key] = [element]
}
}
}
extension Array where Element == String {
func formatList(maxDisplay: Int = 2) -> [String] {
// Check if the array has fewer or equal elements than the maximum display limit
if self.count <= maxDisplay {
// Join all elements with commas
return self
} else {
// Join only the first `maxDisplay` elements and add "et plus"
let displayedItems = self.prefix(maxDisplay)
let remainingCount = self.count - maxDisplay
return displayedItems.dropLast() + [displayedItems.last! + " et \(remainingCount) de plus"]
}
}
}

@ -1,25 +0,0 @@
//
// Badge+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
extension Badge {
func color() -> Color {
switch self {
case .checkmark:
.green
case .xmark:
.logoRed
case .custom(_, let color):
color
}
}
}

@ -0,0 +1,71 @@
//
// Calendar+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 28/03/2024.
//
import Foundation
extension Calendar {
func numberOfDaysBetween(_ from: Date?, and to: Date?) -> Int {
guard let from, let to else { return 0 }
let fromDate = startOfDay(for: from)
let toDate = startOfDay(for: to)
let numberOfDays = dateComponents([.day], from: fromDate, to: toDate)
return numberOfDays.day! // <1>
}
func isSameDay(date1: Date?, date2: Date?) -> Bool {
guard let date1, let date2 else { return false }
return numberOfDaysBetween(date1, and: date2) == 0
}
func getSportAge() -> Int {
let currentDate = Date()
// Get the current year
let currentYear = component(.year, from: currentDate)
// Define the date components for 1st September and 31st December of the current year
let septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1)
let decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31)
// Get the actual dates for 1st September and 31st December
let septemberFirst = date(from: septemberFirstComponents)!
let decemberThirtyFirst = date(from: decemberThirtyFirstComponents)!
// Determine the sport year
let sportYear: Int
if currentDate >= septemberFirst && currentDate <= decemberThirtyFirst {
// If after 1st September and before 31st December, use current year + 1
sportYear = currentYear + 1
} else {
// Otherwise, use the current year
sportYear = currentYear
}
return sportYear
}
}
extension Calendar {
// Add or subtract months from a date
func addMonths(_ months: Int, to date: Date) -> Date {
return self.date(byAdding: .month, value: months, to: date)!
}
// Generate a list of month start dates between two dates
func generateMonthRange(startDate: Date, endDate: Date) -> [Date] {
var dates: [Date] = []
var currentDate = startDate
while currentDate <= endDate {
dates.append(currentDate)
currentDate = self.addMonths(1, to: currentDate)
}
return dates
}
}

@ -0,0 +1,41 @@
//
// KeyedEncodingContainer+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 18/09/2024.
//
import Foundation
import LeStorage
extension KeyedDecodingContainer {
func decodeEncrypted(key: Key) throws -> String {
let data = try self.decode(Data.self, forKey: key)
return try data.decryptData(pass: CryptoKey.pass.rawValue)
}
func decodeEncryptedIfPresent(key: Key) throws -> String? {
let data = try self.decodeIfPresent(Data.self, forKey: key)
if let data {
return try data.decryptData(pass: CryptoKey.pass.rawValue)
}
return nil
}
}
extension KeyedEncodingContainer {
mutating func encodeAndEncryptIfPresent(_ value: Data?, forKey key: Key) throws {
guard let value else {
try encodeNil(forKey: key)
return
}
let encryped: Data = try value.encrypt(pass: CryptoKey.pass.rawValue)
try self.encode(encryped, forKey: key)
}
}

@ -1,22 +0,0 @@
//
// CustomUser+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension CustomUser {
func currentPlayerData() -> ImportedPlayer? {
guard let licenceId = self.licenceId?.strippedLicense else { return nil }
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "license == %@", licenceId)
fetchRequest.predicate = predicate
return try? federalContext.fetch(fetchRequest).first
}
}

@ -0,0 +1,262 @@
//
// Date+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
enum TimeOfDay {
case morning
case noon
case afternoon
case evening
case night
var hello: String {
switch self {
case .morning, .noon, .afternoon:
return "Bonjour"
case .evening, .night:
return "Bonsoir"
}
}
var goodbye: String {
switch self {
case .morning, .noon, .afternoon:
return "Bonne journée"
case .evening, .night:
return "Bonne soirée"
}
}
}
extension Date {
func withoutSeconds() -> Date {
let calendar = Calendar.current
return calendar.date(bySettingHour: calendar.component(.hour, from: self),
minute: calendar.component(.minute, from: self),
second: 0,
of: self)!
}
func localizedDate() -> String {
self.formatted(.dateTime.weekday().day().month()) + " à " + self.formattedAsHourMinute()
}
func formattedAsHourMinute() -> String {
formatted(.dateTime.hour().minute())
}
func formattedAsDate() -> String {
formatted(.dateTime.weekday().day(.twoDigits).month().year())
}
var monthYearFormatted: String {
formatted(.dateTime.month(.wide).year(.defaultDigits))
}
var twoDigitsYearFormatted: String {
formatted(Date.FormatStyle(date: .numeric, time: .omitted).locale(Locale(identifier: "fr_FR")).year(.twoDigits))
}
var timeOfDay: TimeOfDay {
let hour = Calendar.current.component(.hour, from: self)
switch hour {
case 6..<12 : return .morning
case 12 : return .noon
case 13..<17 : return .afternoon
case 17..<22 : return .evening
default: return .night
}
}
}
extension Date {
func isInCurrentYear() -> Bool {
let calendar = Calendar.current
let currentYear = calendar.component(.year, from: Date())
let yearOfDate = calendar.component(.year, from: self)
return currentYear == yearOfDate
}
func get(_ components: Calendar.Component..., calendar: Calendar = Calendar.current) -> DateComponents {
return calendar.dateComponents(Set(components), from: self)
}
func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int {
return calendar.component(component, from: self)
}
var tomorrowAtNine: Date {
let currentHour = Calendar.current.component(.hour, from: self)
let startOfDay = Calendar.current.startOfDay(for: self)
if currentHour < 8 {
return Calendar.current.date(byAdding: .hour, value: 9, to: startOfDay)!
} else {
let date = Calendar.current.date(byAdding: .day, value: 1, to: startOfDay)
return Calendar.current.date(byAdding: .hour, value: 9, to: date!)!
}
}
func atBeginningOfDay(hourInt: Int = 9) -> Date {
Calendar.current.date(byAdding: .hour, value: hourInt, to: self.startOfDay)!
}
static var firstDayOfWeek = Calendar.current.firstWeekday
static var capitalizedFirstLettersOfWeekdays: [String] {
let calendar = Calendar.current
// let weekdays = calendar.shortWeekdaySymbols
// return weekdays.map { weekday in
// guard let firstLetter = weekday.first else { return "" }
// return String(firstLetter).capitalized
// }
// Adjusted for the different weekday starts
var weekdays = calendar.veryShortStandaloneWeekdaySymbols
if firstDayOfWeek > 1 {
for _ in 1..<firstDayOfWeek {
if let first = weekdays.first {
weekdays.append(first)
weekdays.removeFirst()
}
}
}
return weekdays.map { $0.capitalized }
}
static var fullMonthNames: [String] {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
return (1...12).compactMap { month in
dateFormatter.setLocalizedDateFormatFromTemplate("MMMM")
let date = Calendar.current.date(from: DateComponents(year: 2000, month: month, day: 1))
return date.map { dateFormatter.string(from: $0) }
}
}
var startOfMonth: Date {
Calendar.current.dateInterval(of: .month, for: self)!.start
}
var endOfMonth: Date {
let lastDay = Calendar.current.dateInterval(of: .month, for: self)!.end
return Calendar.current.date(byAdding: .day, value: -1, to: lastDay)!
}
var startOfPreviousMonth: Date {
let dayInPreviousMonth = Calendar.current.date(byAdding: .month, value: -1, to: self)!
return dayInPreviousMonth.startOfMonth
}
var numberOfDaysInMonth: Int {
Calendar.current.component(.day, from: endOfMonth)
}
// var sundayBeforeStart: Date {
// let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth)
// let numberFromPreviousMonth = startOfMonthWeekday - 1
// return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)!
// }
// New to accomodate for different start of week days
var firstWeekDayBeforeStart: Date {
let startOfMonthWeekday = Calendar.current.component(.weekday, from: startOfMonth)
let numberFromPreviousMonth = startOfMonthWeekday - Self.firstDayOfWeek
return Calendar.current.date(byAdding: .day, value: -numberFromPreviousMonth, to: startOfMonth)!
}
var calendarDisplayDays: [Date] {
var days: [Date] = []
// Current month days
for dayOffset in 0..<numberOfDaysInMonth {
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfMonth)
days.append(newDay!)
}
// previous month days
for dayOffset in 0..<startOfPreviousMonth.numberOfDaysInMonth {
let newDay = Calendar.current.date(byAdding: .day, value: dayOffset, to: startOfPreviousMonth)
days.append(newDay!)
}
// Fixed to accomodate different weekday starts
return days.filter { $0 >= firstWeekDayBeforeStart && $0 <= endOfMonth }.sorted(by: <)
}
var monthInt: Int {
Calendar.current.component(.month, from: self)
}
var yearInt: Int {
Calendar.current.component(.year, from: self)
}
var dayInt: Int {
Calendar.current.component(.day, from: self)
}
var startOfDay: Date {
Calendar.current.startOfDay(for: self)
}
func endOfDay() -> Date {
let calendar = Calendar.current
return calendar.date(bySettingHour: 23, minute: 59, second: 59, of: self)!
}
func atNine() -> Date {
let calendar = Calendar.current
return calendar.date(bySettingHour: 9, minute: 0, second: 0, of: self)!
}
func atEightAM() -> Date {
let calendar = Calendar.current
return calendar.date(bySettingHour: 8, minute: 0, second: 0, of: self)!
}
}
extension Date {
func isEarlierThan(_ date: Date) -> Bool {
Calendar.current.compare(self, to: date, toGranularity: .minute) == .orderedAscending
}
}
extension Date {
func localizedTime() -> String {
self.formattedAsHourMinute()
}
func localizedDay() -> String {
self.formatted(.dateTime.weekday(.wide).day())
}
func localizedWeekDay() -> String {
self.formatted(.dateTime.weekday(.wide))
}
func timeElapsedString() -> String {
let timeInterval = abs(Date().timeIntervalSince(self))
let duration = Duration.seconds(timeInterval)
let formatStyle = Duration.UnitsFormatStyle(allowedUnits: [.hours, .minutes], width: .narrow)
return formatStyle.format(duration)
}
static var hourMinuteFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute] // Customize units
formatter.unitsStyle = .abbreviated // You can choose .abbreviated or .short
return formatter
}()
func truncateMinutesAndSeconds() -> Date {
let calendar = Calendar.current
return calendar.date(bySetting: .minute, value: 0, of: self)!.withoutSeconds()
}
}

@ -0,0 +1,43 @@
//
// FixedWidthInteger+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
public extension FixedWidthInteger {
func ordinalFormattedSuffix(feminine: Bool = false) -> String {
switch self {
case 1: return feminine ? "ère" : "er"
default: return "ème"
}
}
func ordinalFormatted(feminine: Bool = false) -> String {
return self.formatted() + self.ordinalFormattedSuffix(feminine: feminine)
}
private var isMany: Bool {
self > 1 || self < -1
}
var pluralSuffix: String {
return isMany ? "s" : ""
}
func localizedPluralSuffix(_ plural: String = "s") -> String {
return isMany ? plural : ""
}
func formattedAsRawString() -> String {
String(self)
}
func durationInHourMinutes() -> String {
let duration = Duration.seconds(self*60)
let formatStyle = Duration.UnitsFormatStyle(allowedUnits: [.hours, .minutes], width: .narrow)
return formatStyle.format(duration)
}
}

@ -0,0 +1,28 @@
//
// Locale+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 03/04/2024.
//
import Foundation
extension Locale {
static func countries() -> [String] {
var countries: [String] = []
for countryCode in Locale.Region.isoRegions {
if let countryName = Locale.current.localizedString(forRegionCode: countryCode.identifier) {
countries.append(countryName)
}
}
return countries.sorted()
}
static func defaultCurrency() -> String {
// return "EUR"
Locale.current.currency?.identifier ?? "EUR"
}
}

@ -1,49 +0,0 @@
//
// MonthData+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension MonthData {
static func calculateCurrentUnrankedValues(fromDate: Date) async {
let fileURL = SourceFileManager.shared.allFiles(true).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
print("calculateCurrentUnrankedValues", fromDate.monthYearFormatted, fileURL?.path())
let fftImportingUncomplete = fileURL?.fftImportingUncomplete()
var fftImportingAnonymous = fileURL?.fftImportingAnonymous()
let fftImportingMaleUnrankValue = fileURL?.fftImportingMaleUnrankValue()
let femaleFileURL = SourceFileManager.shared.allFiles(false).first(where: { $0.dateFromPath == fromDate && $0.index == 0 })
let femaleFftImportingMaleUnrankValue = femaleFileURL?.fftImportingMaleUnrankValue()
let femaleFftImportingUncomplete = femaleFileURL?.fftImportingUncomplete()
let incompleteMode = fftImportingUncomplete != nil
let lastDataSourceMaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: true)
let lastDataSourceFemaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: false)
if fftImportingAnonymous == nil {
fftImportingAnonymous = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate)
}
let anonymousCount: Int? = fftImportingAnonymous
await MainActor.run {
let lastDataSource = URL.importDateFormatter.string(from: fromDate)
let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource)
currentMonthData.dataModelIdentifier = PersistenceController.getModelVersion()
currentMonthData.fileModelIdentifier = fileURL?.fileModelIdentifier()
currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0
currentMonthData.incompleteMode = incompleteMode
currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1
currentMonthData.femaleUnrankedValue = incompleteMode ? femaleFftImportingMaleUnrankValue : lastDataSourceFemaleUnranked?.0
currentMonthData.femaleCount = incompleteMode ? femaleFftImportingUncomplete : lastDataSourceFemaleUnranked?.1
currentMonthData.anonymousCount = anonymousCount
DataStore.shared.monthData.addOrUpdate(instance: currentMonthData)
}
}
}

@ -0,0 +1,27 @@
//
// MySortDescriptor.swift
// PadelClub
//
// Created by Razmig Sarkissian on 26/03/2024.
//
import Foundation
struct MySortDescriptor<Value> {
var comparator: (Value, Value) -> ComparisonResult
}
extension MySortDescriptor {
static func keyPath<T: Comparable>(_ keyPath: KeyPath<Value, T>) -> Self {
Self { rootA, rootB in
let valueA = rootA[keyPath: keyPath]
let valueB = rootB[keyPath: keyPath]
guard valueA != valueB else {
return .orderedSame
}
return valueA < valueB ? .orderedAscending : .orderedDescending
}
}
}

@ -0,0 +1,16 @@
//
// NumberFormatter+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 27/03/2024.
//
import Foundation
extension NumberFormatter {
static var ordinal: NumberFormatter {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter
}
}

@ -1,230 +0,0 @@
//
// PlayerRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension PlayerRegistration {
convenience init(importedPlayer: ImportedPlayer) {
self.init()
self.teamRegistration = ""
self.firstName = (importedPlayer.firstName ?? "").prefixTrimmed(50).capitalized
self.lastName = (importedPlayer.lastName ?? "").prefixTrimmed(50).uppercased()
self.licenceId = importedPlayer.license?.prefixTrimmed(50) ?? nil
self.rank = Int(importedPlayer.rank)
self.sex = importedPlayer.male ? .male : .female
self.tournamentPlayed = importedPlayer.tournamentPlayed
self.points = importedPlayer.getPoints()
self.clubName = importedPlayer.clubName?.prefixTrimmed(200)
self.clubCode = importedPlayer.clubCode?.replaceCharactersFromSet(characterSet: .whitespaces).prefixTrimmed(20)
self.ligueName = importedPlayer.ligueName?.prefixTrimmed(200)
self.assimilation = importedPlayer.assimilation?.prefixTrimmed(50)
self.source = .frenchFederation
self.birthdate = importedPlayer.birthYear?.prefixTrimmed(50)
}
convenience init?(federalData: [String], sex: Int, sexUnknown: Bool) {
self.init()
let _lastName = federalData[0].trimmed.uppercased()
let _firstName = federalData[1].trimmed.capitalized
if _lastName.isEmpty && _firstName.isEmpty { return nil }
lastName = _lastName.prefixTrimmed(50)
firstName = _firstName.prefixTrimmed(50)
birthdate = federalData[2].formattedAsBirthdate().prefixTrimmed(50)
licenceId = federalData[3].prefixTrimmed(50)
clubName = federalData[4].prefixTrimmed(200)
let stringRank = federalData[5]
if stringRank.isEmpty {
rank = nil
} else {
rank = Int(stringRank)
}
let _email = federalData[6]
if _email.isEmpty == false {
self.email = _email.prefixTrimmed(50)
}
let _phoneNumber = federalData[7]
if _phoneNumber.isEmpty == false {
self.phoneNumber = _phoneNumber.prefixTrimmed(50)
}
source = .beachPadel
if sexUnknown {
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) {
self.sex = .female
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) {
self.sex = .male
} else {
self.sex = nil
}
} else {
self.sex = PlayerSexType(rawValue: sex)
}
}
}
extension PlayerRegistration {
func hasHomonym() -> Bool {
let federalContext = PersistenceController.shared.localContainer.viewContext
let fetchRequest = ImportedPlayer.fetchRequest()
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName)
fetchRequest.predicate = predicate
do {
let count = try federalContext.count(for: fetchRequest)
return count > 1
} catch {
}
return false
}
func updateRank(from sources: [CSVParser], lastRank: Int?) async throws {
#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
if let dataFound = try await history(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else if let dataFound = try await historyFromName(from: sources) {
rank = dataFound.rankValue?.toInt()
points = dataFound.points
tournamentPlayed = dataFound.tournamentCountValue?.toInt()
} else {
rank = lastRank
}
}
func history(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG_TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func history()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let license = licenceId?.strippedLicense else {
return nil // Do NOT call historyFromName here, let updateRank handle it
}
let filteredSources = sources.filter { $0.maleData == isMalePlayer() }
return await withTaskGroup(of: Line?.self) { group in
for source in filteredSources {
group.addTask {
guard !Task.isCancelled else { return nil }
return try? await source.first { $0.rawValue.contains(";\(license);") }
}
}
for await result in group {
if let result {
group.cancelAll() // Stop other tasks as soon as we find a match
return result
}
}
return nil
}
}
func historyFromName(from sources: [CSVParser]) async throws -> Line? {
#if DEBUG
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func historyFromName()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
let filteredSources = sources.filter { $0.maleData == isMalePlayer() }
let normalizedLastName = lastName.canonicalVersionWithPunctuation
let normalizedFirstName = firstName.canonicalVersionWithPunctuation
return await withTaskGroup(of: Line?.self) { group in
for source in filteredSources {
group.addTask {
guard !Task.isCancelled else { print("Cancelled"); return nil }
return try? await source.first {
let lineValue = $0.rawValue.canonicalVersionWithPunctuation
return lineValue.contains(";\(normalizedLastName);\(normalizedFirstName);")
}
}
}
for await result in group {
if let result {
group.cancelAll() // Stop other tasks as soon as we find a match
return result
}
}
return nil
}
}
}
extension PlayerRegistration: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
nil
}
func getFirstName() -> String {
firstName
}
func getLastName() -> String {
lastName
}
func getPoints() -> Double? {
self.points
}
func getRank() -> Int? {
rank
}
func isUnranked() -> Bool {
rank == nil
}
func formattedRank() -> String {
self.rankLabel()
}
func formattedLicense() -> String {
if let licenceId { return licenceId.computedLicense }
return "aucune licence"
}
var male: Bool {
isMalePlayer()
}
func getBirthYear() -> Int? {
nil
}
func getProgression() -> Int {
0
}
func getComputedRank() -> Int? {
computedRank
}
}

@ -1,33 +0,0 @@
//
// Round+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 30/04/2025.
//
import Foundation
import PadelClubData
extension Round {
func loserBracketTurns() -> [LoserRound] {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func loserBracketTurns()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
var rounds = [LoserRound]()
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index)
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount)
for index in 0..<roundCount {
let lr = LoserRound(roundIndex: roundCount - index - 1, turnIndex: index, upperBracketRound: self)
rounds.append(lr)
}
return rounds
}
}

@ -0,0 +1,87 @@
//
// Sequence+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
extension Collection {
/// Returns the element at the specified index if it is within bounds, otherwise nil.
subscript (safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}
extension Sequence {
func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
return sorted { a, b in
return a[keyPath: keyPath] < b[keyPath: keyPath]
}
}
}
extension Sequence {
func pairs() -> AnySequence<(Element, Element)> {
AnySequence(zip(self, self.dropFirst()))
}
}
extension Sequence {
func concurrentForEach(
_ operation: @escaping (Element) async throws -> Void
) async throws {
// A task group automatically waits for all of its
// sub-tasks to complete, while also performing those
// tasks in parallel:
try await withThrowingTaskGroup(of: Void.self) { group in
for element in self {
group.addTask {
try await operation(element)
}
for try await _ in group {}
}
}
}
}
enum SortOrder {
case ascending
case descending
}
extension Sequence {
func sorted(using descriptors: [MySortDescriptor<Element>],
order: SortOrder) -> [Element] {
sorted { valueA, valueB in
for descriptor in descriptors {
let result = descriptor.comparator(valueA, valueB)
switch result {
case .orderedSame:
// Keep iterating if the two elements are equal,
// since that'll let the next descriptor determine
// the sort order:
break
case .orderedAscending:
return order == .ascending
case .orderedDescending:
return order == .descending
}
}
// If no descriptor was able to determine the sort
// order, we'll default to false (similar to when
// using the '<' operator with the built-in API):
return false
}
}
}
extension Sequence {
func sorted(using descriptors: MySortDescriptor<Element>...) -> [Element] {
sorted(using: descriptors, order: .ascending)
}
}

@ -1,34 +0,0 @@
//
// SourceFileManager+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import LeStorage
import PadelClubData
extension SourceFileManager {
func exportToCSV(_ prefix: String = "", players: [FederalPlayer], sourceFileType: SourceFile, date: Date) {
let lastDateString = URL.importDateFormatter.string(from: date)
let dateString = [prefix, "CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].filter({ $0.isEmpty == false }).joined(separator: "-") + "." + "csv"
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)!
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
var csvText : String = ""
for player in players {
csvText.append(player.exportToCSV() + "\n")
}
do {
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8)
print("CSV file exported successfully.")
} catch {
print("Error writing CSV file:", error)
Logger.error(error)
}
}
}

@ -1,42 +0,0 @@
//
// SpinDrawable+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import PadelClubData
extension String: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
[self]
}
}
extension Match: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
let teams = teams()
if teams.count == 1, hideNames == false {
return teams.first!.segmentLabel(displayStyle, hideNames: hideNames)
} else {
return [roundTitle(), matchTitle(displayStyle)].compactMap { $0 }
}
}
}
extension TeamRegistration: SpinDrawable {
public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] {
var strings: [String] = []
let indexLabel = tournamentObject()?.labelIndexOf(team: self)
if let indexLabel {
strings.append(indexLabel)
if hideNames {
return strings
}
}
strings.append(contentsOf: self.players().map { $0.playerLabel(displayStyle) })
return strings
}
}

@ -0,0 +1,47 @@
//
// String+Crypto.swift
// PadelClub
//
// Created by Laurent Morvillier on 30/04/2024.
//
import Foundation
import CryptoKit
enum CryptoError: Error {
case invalidUTF8
case cantConvertUTF8
case invalidBase64String
case nilSeal
}
extension Data {
func encrypt(pass: String) throws -> Data {
let key = try self._createSymmetricKey(fromString: pass)
let sealedBox = try AES.GCM.seal(self, using: key)
if let combined = sealedBox.combined {
return combined
}
throw CryptoError.nilSeal
}
func decryptData(pass: String) throws -> String {
let key = try self._createSymmetricKey(fromString: pass)
let sealedBox = try AES.GCM.SealedBox(combined: self)
let decryptedData = try AES.GCM.open(sealedBox, using: key)
guard let decryptedMessage = String(data: decryptedData, encoding: .utf8) else {
throw CryptoError.invalidUTF8
}
return decryptedMessage
}
fileprivate func _createSymmetricKey(fromString keyString: String) throws -> SymmetricKey {
guard let keyData = Data(base64Encoded: keyString) else {
throw CryptoError.invalidBase64String
}
return SymmetricKey(data: keyData)
}
}

@ -0,0 +1,286 @@
//
// String+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
// MARK: - Trimming and stuff
extension String {
func trunc(length: Int, trailing: String = "") -> String {
if length <= 0 { return self }
return (self.count > length) ? self.prefix(length) + trailing : self
}
func prefixTrimmed(_ length: Int) -> String {
String(trimmed.prefix(length))
}
func prefixMultilineTrimmed(_ length: Int) -> String {
String(trimmedMultiline.prefix(length))
}
var trimmed: String {
replaceCharactersFromSet(characterSet: .newlines, replacementString: " ").trimmingCharacters(in: .whitespacesAndNewlines)
}
var trimmedMultiline: String {
self.trimmingCharacters(in: .whitespacesAndNewlines)
}
func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String {
components(separatedBy: characterSet).joined(separator:replacementString)
}
var canonicalVersion: String {
trimmed.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ").folding(options: .diacriticInsensitive, locale: .current).lowercased()
}
var canonicalVersionWithPunctuation: String {
trimmed.folding(options: .diacriticInsensitive, locale: .current).lowercased()
}
var removingFirstCharacter: String {
String(dropFirst())
}
func isValidEmail() -> Bool {
let emailRegEx = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$"
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx)
return emailPredicate.evaluate(with: self)
}
}
// MARK: - Club Name
extension String {
func acronym() -> String {
let acronym = canonicalVersion.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines)
if acronym.count > 10 {
return concatenateFirstLetters().uppercased()
} else {
return acronym.uppercased()
}
}
func concatenateFirstLetters() -> String {
// Split the input into sentences
let sentences = self.components(separatedBy: .whitespacesAndNewlines)
if sentences.count == 1 {
return String(self.prefix(10))
}
// Extract the first character of each sentence
let firstLetters = sentences.compactMap { sentence -> Character? in
let trimmedSentence = sentence.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedSentence.count > 2 {
if let firstCharacter = trimmedSentence.first {
return firstCharacter
}
}
return nil
}
// Join the first letters together into a string
let result = String(firstLetters)
return String(result.prefix(10))
}
}
// MARK: - FFT License
extension String {
var computedLicense: String {
if let licenseKey {
return self + licenseKey
} else {
return self
}
}
var strippedLicense: String? {
var dropFirst = 0
if hasPrefix("0") {
dropFirst = 1
}
if let match = self.dropFirst(dropFirst).firstMatch(of: /[0-9]{6,8}/) {
let lic = String(self.dropFirst(dropFirst)[match.range.lowerBound..<match.range.upperBound])
return lic
} else {
return nil
}
}
var isLicenseNumber: Bool {
if let match = self.firstMatch(of: /[0-9]{6,8}[A-Z]/) {
let lic = String(self[match.range.lowerBound..<match.range.upperBound].dropLast(1))
let lastLetter = String(self[match.range.lowerBound..<match.range.upperBound].suffix(1))
if let lkey = lic.licenseKey {
return lkey == lastLetter
}
}
return false
}
func hasLicenseKey() -> Bool {
if let match = self.firstMatch(of: /[0-9]{6,8}[A-Z]/) {
return true
} else {
return false
}
}
var licenseKey: String? {
if let intValue = Int(self) {
var value = intValue
value -= 1
value = value % 23
let v = UnicodeScalar("A").value
let i = Int(v)
if let s = UnicodeScalar(i + value) {
var c = Character(s)
if c >= "I" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
if c >= "O" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
if c >= "Q" {
value += 1
if let newS = UnicodeScalar(i + value) {
c = Character(newS)
}
}
return String(c)
}
}
return nil
}
func licencesFound() -> [String] {
let matches = self.matches(of: /[1-9][0-9]{5,7}/)
return matches.map { String(self[$0.range]) }
}
func isValidCodeClub(_ codeClubPrefix: Int) -> Bool {
let code = trimmed.replaceCharactersFromSet(characterSet: .whitespaces)
guard code.hasPrefix(String(codeClubPrefix)) else { return false }
guard code.count == 8 else { return false }
return true
}
}
// MARK: - FFT Source Importing
extension String {
enum RegexStatic {
static let mobileNumber = /^0[6-7]/
//static let mobileNumber = /^(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})$/
}
func isMobileNumber() -> Bool {
firstMatch(of: RegexStatic.mobileNumber) != nil
}
//april 04-2024 bug with accent characters / adobe / fft
mutating func replace(characters: [(Character, Character)]) {
for (targetChar, replacementChar) in characters {
self = String(self.map { $0 == targetChar ? replacementChar : $0 })
}
}
}
// MARK: - Player Names
extension StringProtocol {
var firstUppercased: String { prefix(1).uppercased() + dropFirst() }
var firstCapitalized: String { prefix(1).capitalized + dropFirst() }
}
// MARK: - todo clean up ??
extension LosslessStringConvertible {
var string: String { .init(self) }
}
extension String {
func createFile(_ withName: String = "temp", _ exportedFormat: ExportFormat = .rawText) -> URL {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent(withName)
.appendingPathExtension(exportedFormat.suffix)
let string = self
try? FileManager.default.removeItem(at: url)
// Add BOM for Excel to properly recognize UTF-8
if exportedFormat == .championship || exportedFormat == .csv {
let bom = Data([0xEF, 0xBB, 0xBF])
let stringData = string.data(using: .utf8)!
let dataWithBOM = bom + stringData
try? dataWithBOM.write(to: url, options: .atomic)
} else {
// For other formats, write normally with UTF-8
try? string.write(to: url, atomically: true, encoding: .utf8)
}
return url
}
}
extension String {
func toInt() -> Int? {
Int(self)
}
}
extension String : @retroactive Identifiable {
public var id: String { self }
}
extension String {
/// Parses the birthdate string into a `Date` based on multiple formats.
/// - Returns: A `Date` object if parsing is successful, or `nil` if the format is unrecognized.
func parseAsBirthdate() -> Date? {
let dateFormats = [
"yyyy-MM-dd", // Format for "1993-01-31"
"dd/MM/yyyy", // Format for "27/07/1992"
"dd/MM/yy" // Format for "27/07/92"
]
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Ensure consistent parsing
for format in dateFormats {
dateFormatter.dateFormat = format
if let date = dateFormatter.date(from: self) {
return date // Return the parsed date if successful
}
}
return nil // Return nil if no format matches
}
/// Formats the birthdate string into "DD/MM/YYYY".
/// - Returns: A formatted birthdate string, or the original string if parsing fails.
func formattedAsBirthdate() -> String {
if let parsedDate = self.parseAsBirthdate() {
let outputFormatter = DateFormatter()
outputFormatter.dateFormat = "dd/MM/yyyy" // Desired output format
return outputFormatter.string(from: parsedDate)
}
return self // Return the original string if parsing fails
}
}

@ -1,84 +0,0 @@
//
// TeamRegistration+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
extension TeamRegistration {
func initialRoundColor() -> Color? {
if walkOut { return Color.logoRed }
if groupStagePosition != nil || wildCardGroupStage { return Color.blue }
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] {
return Color(uiColor: .init(fromHex: colorHex))
} else if wildCardBracket {
return Color.mint
} else {
return nil
}
}
func updateWeight(inTournamentCategory tournamentCategory: TournamentCategory) {
self.setWeight(from: self.players(), inTournamentCategory: tournamentCategory)
}
func updatePlayers(
_ players: Set<PlayerRegistration>,
inTournamentCategory tournamentCategory: TournamentCategory
) {
let previousPlayers = Set(unsortedPlayers())
players.forEach { player in
previousPlayers.forEach { oldPlayer in
if player.licenceId?.strippedLicense == oldPlayer.licenceId?.strippedLicense,
player.licenceId?.strippedLicense != nil
{
player.registeredOnline = oldPlayer.registeredOnline
if player.email?.canonicalVersion != oldPlayer.email?.canonicalVersion {
player.contactEmail = oldPlayer.email
} else {
player.contactEmail = oldPlayer.contactEmail
}
if areFrenchPhoneNumbersSimilar(player.phoneNumber, oldPlayer.phoneNumber) == false {
player.contactPhoneNumber = oldPlayer.phoneNumber
} else {
player.contactPhoneNumber = oldPlayer.contactPhoneNumber
}
player.contactName = oldPlayer.contactName
player.coach = oldPlayer.coach
player.tournamentPlayed = oldPlayer.tournamentPlayed
player.points = oldPlayer.points
player.captain = oldPlayer.captain
player.assimilation = oldPlayer.assimilation
player.ligueName = oldPlayer.ligueName
player.registrationStatus = oldPlayer.registrationStatus
player.timeToConfirm = oldPlayer.timeToConfirm
player.sex = oldPlayer.sex
player.paymentType = oldPlayer.paymentType
player.paymentId = oldPlayer.paymentId
player.clubMember = oldPlayer.clubMember
}
}
}
let playersToRemove = previousPlayers.subtracting(players)
self.tournamentStore?.playerRegistrations.delete(contentOfs: Array(playersToRemove))
setWeight(from: Array(players), inTournamentCategory: tournamentCategory)
players.forEach { player in
player.teamRegistration = id
}
// do {
// try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: players)
// } catch {
// Logger.error(error)
// }
}
}

@ -1,428 +0,0 @@
//
// Tournament+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
import LeStorage
extension Tournament {
func addTeam(_ players: Set<PlayerRegistration>, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration {
let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date(), 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
}
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 addEmptyTeamRegistration(_ count: Int) {
guard let tournamentStore = self.tournamentStore else { return }
let teams = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
return team
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
}
func addWildCard(_ count: Int, _ type: MatchType) {
let wcs = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
if type == .bracket {
team.wildCardBracket = true
} else {
team.wildCardGroupStage = true
}
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
team.weight += 200_000
return team
}
do {
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: wcs)
} catch {
Logger.error(error)
}
}
func teamsRanked() -> [TeamRegistration] {
let selected = selectedSortedTeams().filter({ $0.finalRanking != nil })
return selected.sorted(by: \.finalRanking!, order: .ascending)
}
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 homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] {
players.filter({ $0.hasHomonym() })
}
func payIfNecessary() async throws {
if self.payment != nil { return }
if let payment = await Guard.main.paymentForNewTournament() {
self.payment = payment
DataStore.shared.tournaments.addOrUpdate(instance: self)
return
}
throw PaymentError.cantPayTournament
}
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 isPlayerAgeInadequate(player: PlayerHolder) -> Bool {
guard let computedAge = player.computedAge else { return false }
if federalTournamentAge.isAgeValid(age: computedAge) == false {
return true
} else {
return false
}
}
func isPlayerRankInadequate(player: PlayerHolder) -> Bool {
guard let rank = player.getRank() else { return false }
let _rank = player.male ? rank : rank + addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0)
if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge, seasonYear: startDate.seasonYear()) {
return true
} else {
return false
}
}
func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerRankInadequate(player: player)
}
}
func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerAgeInadequate(player: player)
}
}
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)
let playersToImport = teams.flatMap { $0.players }
tournamentStore.playerRegistrations.addOrUpdate(contentOfs: playersToImport)
}
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty {
setGroupStage(randomize: groupStageSortMode == .random)
}
}
func registrationIssues(selectedTeams: [TeamRegistration]) async -> Int {
let players : [PlayerRegistration] = selectedTeams.flatMap { $0.players() }
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 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
DataStore.shared.monthData.addOrUpdate(instance: newMonthData)
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()
for player in players {
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)
}
}
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
}
}
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams)
refreshRanking = false
}
}
extension Tournament {
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, currencyCode: Locale.defaultCurrency())
}
}
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 {
public func buildHolderTitle(_ displayStyle: DisplayStyle) -> String {
tournamentTitle(.short)
}
public var category: TournamentCategory {
tournamentCategory
}
public var level: TournamentLevel {
tournamentLevel
}
public var age: FederalTournamentAge {
federalTournamentAge
}
}
// MARK: - UI extensions
extension Tournament {
public var shouldShowPaymentInfo: Bool {
if self.payment != nil {
return false
}
switch self.state() {
case .initial, .build, .running:
return true
default:
return false
}
}
}
//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
// }
//}

@ -0,0 +1,182 @@
//
// URL+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
extension URL {
static var savedDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "DD/MM/yyyy"
return df
}()
static var importDateFormatter: DateFormatter = {
let df = DateFormatter()
df.dateFormat = "MM-yyyy"
return df
}()
var dateFromPath: Date {
let found = deletingPathExtension().path().components(separatedBy: "-").suffix(2).joined(separator: "-")
if let date = URL.importDateFormatter.date(from: found) {
return date
} else {
return Date()
}
}
var index: Int {
if let i = path().dropLast(12).last?.wholeNumberValue {
return i
}
return 0
}
var manData: Bool {
path().contains("MESSIEURS")
}
var womanData: Bool {
path().contains("DAMES")
}
static var seed: URL? {
Bundle.main.url(forResource: "SeedData", withExtension: nil)
}
}
extension URL {
func creationDate() -> Date? {
// Use FileManager to retrieve the file attributes
do {
let fileAttributes = try FileManager.default.attributesOfItem(atPath: self.path())
// Access the creationDate from the file attributes
if let creationDate = fileAttributes[.creationDate] as? Date {
print("File creationDate: \(creationDate)")
return creationDate
} else {
print("creationDate not found.")
}
} catch {
print("Error retrieving file attributes: \(error.localizedDescription)")
}
return nil
}
func fftImportingStatus() -> Int? {
// Read the contents of the file
guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else {
return nil
}
// Split the contents by newline characters
let lines = fileContents.components(separatedBy: .newlines)
//0 means no need to reimport, just recalc
//1 or missing means re-import
if let line = lines.first(where: {
$0.hasPrefix("import-status:")
}) {
return Int(line.replacingOccurrences(of: "import-status:", with: ""))
}
return nil
}
func fftImportingMaleUnrankValue() -> Int? {
// Read the contents of the file
guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else {
return nil
}
// Split the contents by newline characters
let lines = fileContents.components(separatedBy: .newlines)
if let line = lines.first(where: {
$0.hasPrefix("unrank-male-value:")
}) {
return Int(line.replacingOccurrences(of: "unrank-male-value:", with: ""))
}
return nil
}
func fileModelIdentifier() -> String? {
// Read the contents of the file
guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else {
return nil
}
// Split the contents by newline characters
let lines = fileContents.components(separatedBy: .newlines)
if let line = lines.first(where: {
$0.hasPrefix("file-model-version:")
}) {
return line.replacingOccurrences(of: "file-model-version:", with: "")
}
return nil
}
func fftImportingUncomplete() -> Int? {
// Read the contents of the file
guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else {
return nil
}
// Split the contents by newline characters
let lines = fileContents.components(separatedBy: .newlines)
if let line = lines.first(where: {
$0.hasPrefix("max-players:")
}) {
return Int(line.replacingOccurrences(of: "max-players:", with: ""))
}
return nil
}
func getUnrankedValue() -> Int? {
// Read the contents of the file
guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else {
return nil
}
// Split the contents by newline characters
let lines = fileContents.components(separatedBy: .newlines)
// Get the last non-empty line
var lastLine: String?
for line in lines.reversed() {
let trimmedLine = line.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedLine.isEmpty {
lastLine = trimmedLine
break
}
}
guard let rankString = lastLine?.components(separatedBy: ";").dropFirst().first, let rank = Int(rankString) else {
return nil
}
// Define the regular expression pattern
let pattern = "\\b\(NSRegularExpression.escapedPattern(for: rankString))\\b"
// Create the regular expression object
guard let regex = try? NSRegularExpression(pattern: pattern) else {
return nil
}
// Get the matches
let matches = regex.matches(in: fileContents, range: NSRange(fileContents.startIndex..., in: fileContents))
// Return the count of matches
return matches.count + rank - 1
}
}

@ -1,22 +0,0 @@
//
// View+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 29/09/2025.
//
import SwiftUI
extension View {
/// Runs a transform only on iOS 26+, otherwise returns self
@ViewBuilder
func ifAvailableiOS26<Content: View>(
@ViewBuilder transform: (Self) -> Content
) -> some View {
if #available(iOS 26.0, *) {
transform(self)
} else {
self
}
}
}

@ -1,7 +1,4 @@
<ul class="round"> <ul class="round">
<li class="spacer" style="transform: translateY(-20px);"> <li class="spacer">&nbsp;{{roundLabel}}</li>
&nbsp;{{roundLabel}}
<div>{{formatLabel}}</div>
</li>
{{match-template}} {{match-template}}
</ul> </ul>

@ -82,7 +82,6 @@ body{
<caption> <caption>
<h2>{{bracketTitle}}</h2> <h2>{{bracketTitle}}</h2>
<h3>{{bracketStartDate}}</h3> <h3>{{bracketStartDate}}</h3>
<h3>{{formatLabel}}</h3>
</caption> </caption>
<tr> <tr>
<th scope="col" style="visibility:hidden"></th> <th scope="col" style="visibility:hidden"></th>

@ -1,14 +1,8 @@
<li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}; position: relative;"> <li class="game game-top {{entrantOneWon}}" style="visibility:{{hidden}}">
{{entrantOne}} {{entrantOne}}
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionTop}}</div>
</li> </li>
<li class="game game-spacer" style="visibility:{{hidden}}"> <li class="game game-spacer" style="visibility:{{hidden}}"><div class="multiline">{{matchDescription}}</div></li>
<div class="center-match-overlay" style="visibility:{{hidden}};">{{centerMatchText}}</div> <li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}">
</li> {{entrantTwo}}
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}; position: relative;">
<div style="transform: translateY(-100%);">
{{entrantTwo}}
</div>
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionBottom}}</div>
</li> </li>
<li class="spacer">&nbsp;</li> <li class="spacer">&nbsp;</li>

@ -1,4 +1,3 @@
<div class="player">{{teamIndex}}</div>
<div class="player">{{playerOne}}<span>{{weightOne}}</span></div> <div class="player">{{playerOne}}<span>{{weightOne}}</span></div>
<div class="player">{{playerTwo}}<span>{{weightTwo}}</span></div> <div class="player">{{playerTwo}}<span>{{weightTwo}}</span></div>

@ -9,7 +9,6 @@
flex-direction:row; flex-direction:row;
padding: 1%; padding: 1%;
} }
.round{ .round{
display:flex; display:flex;
flex-direction:column; flex-direction:column;
@ -28,7 +27,7 @@
.round .spacer{ flex-grow:1; .round .spacer{ flex-grow:1;
font-size:24px; font-size:24px;
text-align: center; text-align: center;
color: #000000; color: #bbb;
font-style:italic; font-style:italic;
} }
.round .spacer:first-child, .round .spacer:first-child,
@ -66,7 +65,7 @@
li.game-spacer{ li.game-spacer{
border-right:2px solid #4f7a38; border-right:2px solid #4f7a38;
min-height:{{minHeight}}px; min-height:156px;
text-align: right; text-align: right;
display : flex; display : flex;
justify-content: center; justify-content: center;
@ -92,40 +91,11 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.game {
/* Ensure the game container is a positioning context for the overlay */
position: relative;
/* Add any other existing styles for your game list items */
}
.match-description-overlay {
/* Position the overlay directly on top of the game item */
position: absolute;
top: 0;
left: 0;
transform: translateY(100%);
width: 100%;
height: 100%;
display: flex; /* Enable flexbox for centering */
justify-content: center; /* Center horizontally */
align-items: center; /* Center vertically (if needed) */
font-size: 1em; /* Optional: Adjust font size */
/* Add any other desired styling for the overlay */
}
.center-match-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 0.8em;
white-space: nowrap; /* Prevents text from wrapping */
}
</style> </style>
</head> </head>
<body> <body>
<h3 style="visibility:{{titleHidden}}">{{tournamentTitle}} - {{tournamentStartDate}}</h3> <h1>{{tournamentTitle}}</h1>
<main id="tournament"> <main id="tournament">
{{brackets}} {{brackets}}
</main> </main>

@ -33,5 +33,7 @@
</array> </array>
<key>ITSAppUsesNonExemptEncryption</key> <key>ITSAppUsesNonExemptEncryption</key>
<false/> <false/>
<key>UIFileSharingEnabled</key>
<true/>
</dict> </dict>
</plist> </plist>

@ -1,60 +0,0 @@
//
// WaitingListView.swift
// PadelClub
//
// Created by razmig on 26/02/2025.
//
import SwiftUI
struct WaitingListView: View {
@Environment(Tournament.self) var tournament: Tournament
let teamCount: Int
@ViewBuilder
var body: some View {
Text("Attention, l'inscription en ligne est activée et vous avez des équipes inscrites en ligne, en modifiant la structure ces équipes seront intégrées ou retirées de votre sélection d'équipes. Pour l'instant Padel Club ne saura pas les prévenir automatiquement, vous devrez les contacter via l'écran de gestion des inscriptions.")
.foregroundStyle(.logoRed)
let selection = tournament.selectedSortedTeams()
if teamCount > tournament.teamCount {
Section {
let teams = tournament.waitingListSortedTeams(selectedSortedTeams: selection)
.prefix(teamCount - tournament.teamCount)
.filter { $0.hasRegisteredOnline() }
ForEach(teams) { team in
NavigationLink {
EditingTeamView(team: team)
.environment(tournament)
} label: {
TeamRowView(team: team)
}
}
} header: {
Text("Équipes entrantes dans la sélection")
} footer: {
Text("Équipes inscrites en ligne à prévenir rentrant dans votre liste")
}
}
if teamCount < tournament.teamCount {
Section {
let teams = selection.suffix(tournament.teamCount - teamCount)
.filter { $0.hasRegisteredOnline() }
ForEach(teams) { team in
NavigationLink {
EditingTeamView(team: team)
.environment(tournament)
} label: {
TeamRowView(team: team)
}
}
} header: {
Text("Équipes sortantes de la sélection")
} footer: {
Text("Équipes inscrites en ligne à prévenir retirées de votre liste")
}
}
}
}

@ -8,7 +8,6 @@
import SwiftUI import SwiftUI
import LeStorage import LeStorage
import TipKit import TipKit
import PadelClubData
@main @main
struct PadelClubApp: App { struct PadelClubApp: App {
@ -18,11 +17,8 @@ struct PadelClubApp: App {
@StateObject var dataStore = DataStore.shared @StateObject var dataStore = DataStore.shared
@State private var registrationError: RegistrationError? = nil @State private var registrationError: RegistrationError? = nil
@State private var importObserverViewModel = ImportObserver() @State private var importObserverViewModel = ImportObserver()
@State private var showDisconnectionAlert: Bool = false
@Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var requiredVersion: String? = nil
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var presentError: Binding<Bool> { var presentError: Binding<Bool> {
@ -66,96 +62,54 @@ struct PadelClubApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
MainView()
if let requiredVersion { .environment(\.horizontalSizeClass, .compact)
DownloadNewVersionView(version: requiredVersion) .alert(isPresented: presentError, error: registrationError) {
} else { Button("Contactez-nous") {
MainView() _openMail()
.environment(\.horizontalSizeClass, .compact) }
.alert(isPresented: presentError, error: registrationError) { Button("Annuler", role: .cancel) {
Button("Contactez-nous") { registrationError = nil
_openMail()
}
Button("Annuler", role: .cancel) {
registrationError = nil
}
} }
.onOpenURL { url in }
.onOpenURL { url in
#if targetEnvironment(simulator) #if targetEnvironment(simulator)
#else #else
_handleIncomingURL(url) _handleIncomingURL(url)
#endif #endif
} }
.environmentObject(networkMonitor) .environmentObject(networkMonitor)
.environmentObject(dataStore) .environmentObject(dataStore)
.environment(importObserverViewModel) .environment(importObserverViewModel)
.environment(navigationViewModel) .environment(navigationViewModel)
.accentColor(.master) .accentColor(.master)
.onAppear { .onAppear {
self._checkVersion()
if ManualPatcher.patchIfPossible(.disconnect) == true {
self.showDisconnectionAlert = true
}
#if DEBUG #if DEBUG
print("Running in Debug mode") print("Running in Debug mode")
#elseif TESTFLIGHT #elseif TESTFLIGHT
print("Running in TestFlight mode") print("Running in TestFlight mode")
#elseif PRODTEST #elseif PRODTEST
print("Running in ProdTest mode") print("Running in ProdTest mode")
#else #else
print("Running in Release mode") print("Running in Release mode")
#endif #endif
print(URLs.main.url) networkMonitor.checkConnection()
networkMonitor.checkConnection() self._onAppear()
self._onAppear() print(PersistenceController.getModelVersion())
print(PersistenceController.getModelVersion())
}
.alert(isPresented: self.$showDisconnectionAlert, content: {
Alert(title: Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données."))
})
.task {
// try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.datastoreLocation(.applicationDefault)
])
}
.environment(\.managedObjectContext, persistenceController.localContainer.viewContext)
}
}
}
fileprivate func _checkVersion() {
Task.detached(priority: .high) {
if let requiredVersion = await self._retrieveRequiredVersion() {
let cleanedRequired = requiredVersion.replacingOccurrences(of: "\n", with: "")
Logger.log(">>> REQUIRED VERSION = \(requiredVersion)")
if let currentVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String {
await MainActor.run {
if VersionComparator.compare(cleanedRequired, currentVersion) == 1 {
self.requiredVersion = cleanedRequired
}
}
} }
} .task {
//try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.daily),
.datastoreLocation(.applicationDefault)
])
}
.environment(\.managedObjectContext, persistenceController.localContainer.viewContext)
} }
} }
fileprivate func _retrieveRequiredVersion() async -> String? {
let requiredVersionURL = URLs.main.extend(path: "static/misc/required-version.txt")
do {
let (data, _) = try await URLSession.shared.data(from: requiredVersionURL)
return String(data: data, encoding: .utf8)
} catch {
Logger.log("Error fetching required version: \(error)")
return nil
}
}
private func _handleIncomingURL(_ url: URL) { private func _handleIncomingURL(_ url: URL) {
// Parse the URL // Parse the URL
let pathComponents = url.pathComponents let pathComponents = url.pathComponents
@ -193,11 +147,11 @@ struct PadelClubApp: App {
navigationViewModel.selectedTab = .umpire navigationViewModel.selectedTab = .umpire
} }
if navigationViewModel.accountPath.isEmpty { if navigationViewModel.umpirePath.isEmpty {
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login) navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
} else if navigationViewModel.accountPath.last! != .login { } else if navigationViewModel.umpirePath.last! != .login {
navigationViewModel.accountPath.removeAll() navigationViewModel.umpirePath.removeAll()
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login) navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
} }
} }
}.resume() }.resume()
@ -219,59 +173,3 @@ struct PadelClubApp: App {
} }
} }
} }
struct DownloadNewVersionView: View {
var version: String
var body: some View {
VStack {
// AngledStripesBackground()
Spacer()
VStack(spacing: 20.0) {
Text("Veuillez télécharger la nouvelle version de Padel Club pour continuer à vous servir de l'app !")
.fontWeight(.semibold)
.foregroundStyle(.white)
.padding()
.background(.logoRed)
.clipShape(.buttonBorder)
// .padding(32.0)
VStack(alignment: .center, spacing: 0.0
) {
Text("Version \(self.version)")
.fontWeight(.bold)
Image(systemName: "square.and.arrow.down").font(.title)
}.padding().background(.logoYellow)
.clipShape(.buttonBorder)
}
.frame(maxWidth: .infinity)
.foregroundStyle(.logoBackground)
.fontWeight(.medium)
.multilineTextAlignment(.center)
.padding(.horizontal, 36.0)
Image("logo").padding(.vertical, 50.0)
Spacer()
}
.background(.logoBackground)
.onTapGesture {
UIApplication.shared.open(URLs.appStore.url)
}
}
}
struct DisconnectionAlertView: View {
var body: some View {
Text("Vous avez été déconnecté. Veuillez vous reconnecter pour récupérer vos données.").multilineTextAlignment(.center).padding()
}
}
#Preview {
DownloadNewVersionView(version: "1.2")
}

@ -1,36 +1,11 @@
{ {
"appPolicies" : {
"eula" : "",
"policies" : [
{
"locale" : "en_US",
"policyText" : "",
"policyURL" : ""
}
]
},
"identifier" : "2055C391", "identifier" : "2055C391",
"nonRenewingSubscriptions" : [ "nonRenewingSubscriptions" : [
], ],
"products" : [ "products" : [
{ {
"displayPrice" : "129.0", "displayPrice" : "14.0",
"familyShareable" : false,
"internalID" : "6751947241",
"localizations" : [
{
"description" : "Achetez 10 tournois",
"displayName" : "Pack de 10 tournois",
"locale" : "fr"
}
],
"productID" : "app.padelclub.tournament.unit.10",
"referenceName" : "Pack de 10 tournois",
"type" : "Consumable"
},
{
"displayPrice" : "17.0",
"familyShareable" : false, "familyShareable" : false,
"internalID" : "6484163993", "internalID" : "6484163993",
"localizations" : [ "localizations" : [
@ -47,53 +22,57 @@
], ],
"settings" : { "settings" : {
"_applicationInternalID" : "6484163558", "_applicationInternalID" : "6484163558",
"_askToBuyEnabled" : false,
"_billingGracePeriodEnabled" : false,
"_billingIssuesEnabled" : false,
"_compatibilityTimeRate" : { "_compatibilityTimeRate" : {
"3" : 6 "3" : 6
}, },
"_developerTeamID" : "BQ3Y44M3Q6", "_developerTeamID" : "BQ3Y44M3Q6",
"_disableDialogs" : false,
"_failTransactionsEnabled" : false, "_failTransactionsEnabled" : false,
"_lastSynchronizedDate" : 779705033.96878397, "_lastSynchronizedDate" : 735034894.72550702,
"_locale" : "fr", "_locale" : "en_US",
"_renewalBillingIssuesEnabled" : false, "_storefront" : "USA",
"_storefront" : "FRA",
"_storeKitErrors" : [ "_storeKitErrors" : [
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Load Products" "name" : "Load Products"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Purchase" "name" : "Purchase"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Verification" "name" : "Verification"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "App Store Sync" "name" : "App Store Sync"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Subscription Status" "name" : "Subscription Status"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "App Transaction" "name" : "App Transaction"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Manage Subscriptions Sheet" "name" : "Manage Subscriptions Sheet"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Refund Request Sheet" "name" : "Refund Request Sheet"
}, },
{ {
"current" : null,
"enabled" : false, "enabled" : false,
"name" : "Offer Code Redeem Sheet" "name" : "Offer Code Redeem Sheet"
} }
@ -110,15 +89,7 @@
"subscriptions" : [ "subscriptions" : [
{ {
"adHocOffers" : [ "adHocOffers" : [
{
"displayPrice" : "45.0",
"internalID" : "1A02CDB5",
"numberOfPeriods" : 12,
"offerID" : "PRICE50",
"paymentMode" : "payAsYouGo",
"referenceName" : "ancien prix 50",
"subscriptionPeriod" : "P1M"
}
], ],
"codeOffers" : [ "codeOffers" : [
@ -139,10 +110,7 @@
"recurringSubscriptionPeriod" : "P1M", "recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Five", "referenceName" : "Monthly Five",
"subscriptionGroupID" : "21474782", "subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription", "type" : "RecurringSubscription"
"winbackOffers" : [
]
}, },
{ {
"adHocOffers" : [ "adHocOffers" : [
@ -167,16 +135,13 @@
"recurringSubscriptionPeriod" : "P1M", "recurringSubscriptionPeriod" : "P1M",
"referenceName" : "Monthly Unlimited", "referenceName" : "Monthly Unlimited",
"subscriptionGroupID" : "21474782", "subscriptionGroupID" : "21474782",
"type" : "RecurringSubscription", "type" : "RecurringSubscription"
"winbackOffers" : [
]
} }
] ]
} }
], ],
"version" : { "version" : {
"major" : 4, "major" : 3,
"minor" : 0 "minor" : 0
} }
} }

@ -1,18 +1,44 @@
// //
// XlsToCsvService.swift // CloudConvert.swift
// PadelClub // Padel Tournament
// //
// Created by razmig on 12/04/2025. // Created by Razmig Sarkissian on 14/09/2023.
// //
import Foundation import Foundation
import LeStorage
class XlsToCsvService { class CloudConvert {
static func exportToCsv(url: URL) async throws -> String { enum CloudConvertionError: LocalizedError {
let service = try StoreCenter.main.service() case unknownError
var request = try service._baseRequest(servicePath: "xls-to-csv/", method: .post, requiresToken: true) case serviceError(ErrorResponse)
case urlNotFound(String)
var errorDescription: String? {
switch self {
case .unknownError:
return "Erreur"
case .serviceError(let errorResponse):
return errorResponse.error
case .urlNotFound(let url):
return "L'URL [\(url)] n'est pas valide"
}
}
}
static let manager = CloudConvert()
func uploadFile(_ url: URL) async throws -> String {
return try await createJob(url)
}
func createJob(_ url: URL) async throws -> String {
let apiPath = "https://\(URLs.activationHost.rawValue)/utils/xls-to-csv/"
guard let taskURL = URL(string: apiPath) else {
throw CloudConvertionError.urlNotFound(apiPath)
}
var request: URLRequest = URLRequest(url: taskURL)
request.httpMethod = "POST"
// Create the boundary string for multipart/form-data // Create the boundary string for multipart/form-data
let boundary = UUID().uuidString let boundary = UUID().uuidString
@ -55,29 +81,13 @@ class XlsToCsvService {
return responseString return responseString
} else { } else {
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide") let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide")
throw ConvertionError.serviceError(error) throw CloudConvertionError.serviceError(error)
} }
} }
} }
// MARK: - ErrorResponse
struct ErrorResponse: Decodable { struct ErrorResponse: Decodable {
let code: Int let code: Int
let status, error: String let status, error: String
} }
enum ConvertionError: LocalizedError {
case unknownError
case serviceError(ErrorResponse)
case urlNotFound(String)
var errorDescription: String? {
switch self {
case .unknownError:
return "Erreur"
case .serviceError(let errorResponse):
return errorResponse.error
case .urlNotFound(let url):
return "L'URL [\(url)] n'est pas valide"
}
}
}

@ -0,0 +1,254 @@
//
// ContactManager.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 19/09/2023.
//
import Foundation
import SwiftUI
import MessageUI
import LeStorage
enum ContactManagerError: LocalizedError {
case mailFailed
case mailNotSent //no network no error
case messageFailed
case messageNotSent //no network no error
case calendarAccessDenied
case calendarEventSaveFailed
case noCalendarAvailable
case uncalledTeams([TeamRegistration])
var localizedDescription: String {
switch self {
case .mailFailed:
return "Le mail n'a pas été envoyé"
case .mailNotSent:
return "Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer."
case .messageFailed:
return "Le SMS n'a pas été envoyé"
case .messageNotSent:
return "Le SMS n'a pas été envoyé"
case .uncalledTeams(let array):
let verb = array.count > 1 ? "peuvent" : "peut"
return "Attention, \(array.count) équipe\(array.count.pluralSuffix) ne \(verb) pas être contacté par la méthode choisie"
case .calendarAccessDenied:
return "Padel Club n'a pas accès à votre calendrier"
case .calendarEventSaveFailed:
return "Padel Club n'a pas réussi à sauver ce tournoi dans votre calendrier"
case .noCalendarAvailable:
return "Padel Club n'a pas réussi à trouver un calendrier pour y inscrire ce tournoi"
}
}
static func getNetworkErrorMessage(sentError: ContactManagerError?, networkMonitorConnected: Bool) -> String {
var errors: [String] = []
if networkMonitorConnected == false {
errors.append("L'appareil n'est pas connecté à internet.")
}
if let sentError {
errors.append(sentError.localizedDescription)
}
return errors.joined(separator: "\n")
}
}
enum ContactType: Identifiable {
case mail(date: Date?, recipients: [String]?, bccRecipients: [String]?, body: String?, subject: String?, tournamentBuild: TournamentBuild?)
case message(date: Date?, recipients: [String]?, body: String?, tournamentBuild: TournamentBuild?)
var id: Int {
switch self {
case .message: return 0
case .mail: return 1
}
}
}
extension ContactType {
static let defaultCustomMessage: String =
"""
Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire.
"""
static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces."
static func callingCustomMessage(source: String? = nil, tournament: Tournament?, startDate: Date?, roundLabel: String) -> String {
let tournamentCustomMessage = source ?? DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage
let clubName = tournament?.clubName ?? ""
var text = tournamentCustomMessage
let date = startDate ?? tournament?.startDate ?? Date()
if let tournament {
text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.short))
text = text.replacingOccurrences(of: "#prix", with: tournament.entryFeeMessage)
}
text = text.replacingOccurrences(of: "#club", with: clubName)
text = text.replacingOccurrences(of: "#manche", with: roundLabel.lowercased())
text = text.replacingOccurrences(of: "#jour", with: "\(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide)))")
text = text.replacingOccurrences(of: "#horaire", with: "\(date.formatted(Date.FormatStyle().hour().minute()))")
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature()
text = text.replacingOccurrences(of: "#signature", with: signature)
return text
}
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, reSummon: Bool = false) -> String {
let useFullCustomMessage = DataStore.shared.user.summonsUseFullCustomMessage
if useFullCustomMessage {
return callingCustomMessage(tournament: tournament, startDate: startDate, roundLabel: roundLabel)
}
let date = startDate ?? tournament?.startDate ?? Date()
let clubName = tournament?.clubName ?? ""
let message = DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature()
let localizedCalled = "convoqué" + (tournament?.tournamentCategory == .women ? "e" : "") + "s"
var entryFeeMessage: String? {
(DataStore.shared.user.summonsDisplayEntryFee) ? tournament?.entryFeeMessage : nil
}
var linkMessage: String? {
if let tournament, tournament.isPrivate == false, let shareLink = tournament.shareURL(.matches)?.absoluteString {
return "Vous pourrez suivre tous les résultats de ce tournoi sur le site :\n\n".appending(shareLink)
} else {
return nil
}
}
var computedMessage: String {
[entryFeeMessage, message, linkMessage].compacted().map { $0.trimmedMultiline }.joined(separator: "\n\n")
}
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes"
if let tournament {
return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)"
} else {
return "Bonjour,\n\n\(intro) \(localizedCalled) \(roundLabel) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire !\n\n\(signature)"
}
}
}
struct MessageComposeView: UIViewControllerRepresentable {
typealias Completion = (_ result: MessageComposeResult) -> Void
static var canSendText: Bool { MFMessageComposeViewController.canSendText() }
let recipients: [String]?
let body: String?
let completion: Completion?
func makeUIViewController(context: Context) -> UIViewController {
guard Self.canSendText else {
let errorView = ContentUnavailableView("Aucun compte de messagerie", systemImage: "xmark", description: Text("Aucun compte de messagerie n'est configuré sur cet appareil."))
return UIHostingController(rootView: errorView)
}
let controller = MFMessageComposeViewController()
controller.messageComposeDelegate = context.coordinator
controller.recipients = recipients
controller.body = body
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: self.completion)
}
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate {
private let completion: Completion?
public init(completion: Completion?) {
self.completion = completion
}
public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) {
controller.dismiss(animated: true, completion: {
self.completion?(result)
})
}
}
}
struct MailComposeView: UIViewControllerRepresentable {
typealias Completion = (_ result: MFMailComposeResult) -> Void
static var canSendMail: Bool {
if let mailURL = URL(string: "mailto:?to=jap@padelclub.com") {
let mailConfigured = UIApplication.shared.canOpenURL(mailURL)
return mailConfigured && MFMailComposeViewController.canSendMail()
} else {
return MFMailComposeViewController.canSendMail()
}
}
let recipients: [String]?
let bccRecipients: [String]?
let body: String?
let subject: String?
var attachmentURL: URL?
let completion: Completion?
func makeUIViewController(context: Context) -> UIViewController {
guard Self.canSendMail else {
let errorView = ContentUnavailableView("Aucun compte mail", systemImage: "xmark", description: Text("Aucun compte mail n'est configuré sur cet appareil."))
return UIHostingController(rootView: errorView)
}
let controller = MFMailComposeViewController()
controller.mailComposeDelegate = context.coordinator
controller.setToRecipients(recipients)
controller.setBccRecipients(bccRecipients)
if let attachmentURL {
do {
let attachmentData = try Data(contentsOf: attachmentURL)
controller.addAttachmentData(attachmentData, mimeType: "application/zip", fileName: "backup.zip")
} catch {
print("Could not attach file: \(error)")
}
}
if let body {
controller.setMessageBody(body, isHTML: false)
}
if let subject {
controller.setSubject(subject)
}
return controller
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(completion: self.completion)
}
class Coordinator: NSObject, MFMailComposeViewControllerDelegate {
private let completion: Completion?
public init(completion: Completion?) {
self.completion = completion
}
public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) {
controller.dismiss(animated: true, completion: {
self.completion?(result)
})
}
}
}

@ -0,0 +1,12 @@
//
// Key.swift
// PadelClub
//
// Created by Laurent Morvillier on 30/04/2024.
//
import Foundation
enum CryptoKey: String {
case pass = "Aa9QDV1G5MP9ijF2FTFasibNbS/Zun4qXrubIL2P+Ik="
}

@ -0,0 +1,64 @@
//
// DisplayContext.swift
// PadelClub
//
// Created by Razmig Sarkissian on 20/03/2024.
//
import Foundation
import UIKit
enum DisplayContext {
case addition
case edition
case lockedForEditing
case selection
}
enum DisplayStyle {
case title
case wide
case short
}
enum SummoningDisplayContext {
case footer
case menu
}
struct DeviceHelper {
static func isBigScreen() -> Bool {
switch UIDevice.current.userInterfaceIdiom {
case .pad: // iPads
return true
case .phone: // iPhones (you can add more cases here for large vs small phones)
if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc.
return true // large phones
} else {
return false // smaller phones
}
default:
return false // Other devices (Apple Watch, TV, etc.)
}
}
static func maxCharacter() -> Int {
switch UIDevice.current.userInterfaceIdiom {
case .pad: // iPads
return 30
case .phone: // iPhones (you can add more cases here for large vs small phones)
if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc.
return 15 // large phones
} else {
return 9 // smaller phones
}
default:
return 9 // Other devices (Apple Watch, TV, etc.)
}
}
static func charLength() -> Int {
isBigScreen() ? 0 : 15
}
}

@ -0,0 +1,38 @@
//
// ExportFormat.swift
// PadelClub
//
// Created by Razmig Sarkissian on 19/07/2024.
//
import Foundation
enum ExportFormat: Int, Identifiable, CaseIterable {
var id: Int { self.rawValue }
case rawText
case csv
case championship
var suffix: String {
switch self {
case .rawText:
return "txt"
case .csv, .championship:
return "csv"
}
}
func separator() -> String {
switch self {
case .rawText:
return " "
case .csv, .championship:
return ";"
}
}
func newLineSeparator(_ count: Int = 1) -> String {
return Array(repeating: "\n", count: count).joined()
}
}

@ -8,7 +8,6 @@
import Foundation import Foundation
import LeStorage import LeStorage
import SwiftUI import SwiftUI
import PadelClubData
enum FileImportManagerError: LocalizedError { enum FileImportManagerError: LocalizedError {
case unknownFormat case unknownFormat
@ -29,6 +28,9 @@ class ImportObserver {
func currentlyImportingLabel() -> String { func currentlyImportingLabel() -> String {
guard let currentImportDate else { return "import en cours" } guard let currentImportDate else { return "import en cours" }
if URL.importDateFormatter.string(from: currentImportDate) == "07-2024" {
return "consolidation des données"
}
return "import " + currentImportDate.monthYearFormatted return "import " + currentImportDate.monthYearFormatted
} }
@ -42,38 +44,32 @@ class ImportObserver {
class FileImportManager { class FileImportManager {
static let shared = FileImportManager() static let shared = FileImportManager()
func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) { func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) {
let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")] let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")]
var playersLeft = Dictionary(uniqueKeysWithValues: players.map { ($0.license, $0) }) var playersLeft = players
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach({ url in
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach { url in if playersLeft.isEmpty == false {
if playersLeft.isEmpty { return } let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
let federalPlayersDict = Dictionary(uniqueKeysWithValues: federalPlayers.map { ($0.license, $0) })
for (license, importedPlayer) in playersLeft {
guard let federalPlayer = federalPlayersDict[license] else { continue }
var lastName = federalPlayer.lastName
var firstName = federalPlayer.firstName
lastName.replace(characters: replacementsCharacters) playersLeft.forEach { importedPlayer in
firstName.replace(characters: replacementsCharacters) if let federalPlayer = federalPlayers.first(where: { $0.license == importedPlayer.license }) {
var lastName = federalPlayer.lastName
importedPlayer.lastName = lastName.trimmed.uppercased() lastName.replace(characters: replacementsCharacters)
importedPlayer.firstName = firstName.trimmed.capitalized var firstName = federalPlayer.firstName
firstName.replace(characters: replacementsCharacters)
playersLeft.removeValue(forKey: license) // Remove processed player importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
}
}
} }
} })
players = Array(playersLeft.values) players = playersLeft
} }
func foundInWomenData(license: String?) -> Bool { func foundInWomenData(license: String?) -> Bool {
guard let license = license?.strippedLicense else { guard let license = license?.strippedLicense else {
return false return false
@ -132,18 +128,18 @@ class FileImportManager {
let weight: Int let weight: Int
let tournamentCategory: TournamentCategory let tournamentCategory: TournamentCategory
let tournamentAgeCategory: FederalTournamentAge let tournamentAgeCategory: FederalTournamentAge
let tournamentLevel: TournamentLevel
let previousTeam: TeamRegistration? let previousTeam: TeamRegistration?
var registrationDate: Date? = nil var registrationDate: Date? = nil
var name: String? = nil var name: String? = nil
var teamChampionship: TeamChampionship?
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, tournamentLevel: TournamentLevel, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) { init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, teamChampionship: TeamChampionship? = nil, tournament: Tournament) {
self.players = Set(players) self.players = Set(players)
self.tournamentCategory = tournamentCategory self.tournamentCategory = tournamentCategory
self.tournamentAgeCategory = tournamentAgeCategory self.tournamentAgeCategory = tournamentAgeCategory
self.tournamentLevel = tournamentLevel
self.name = name self.name = name
self.previousTeam = previousTeam self.previousTeam = previousTeam
self.teamChampionship = teamChampionship
if players.count < 2 { if players.count < 2 {
let s = players.compactMap { $0.sex?.rawValue } let s = players.compactMap { $0.sex?.rawValue }
var missing = tournamentCategory.mandatoryPlayerType() var missing = tournamentCategory.mandatoryPlayerType()
@ -152,9 +148,9 @@ class FileImportManager {
missing.remove(at: index) missing.remove(at: index)
} }
} }
let significantPlayerCount = 2 let significantPlayerCount = tournament.significantPlayerCount()
let pl = players.prefix(significantPlayerCount).map { $0.computedRank } let pl = players.prefix(significantPlayerCount).map { $0.computedRank }
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 92_327 : 10_000) }).prefix(significantPlayerCount) let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 70_000 : 10_000) }).prefix(significantPlayerCount)
self.weight = pl.reduce(0,+) + missingPl.reduce(0,+) self.weight = pl.reduce(0,+) + missingPl.reduce(0,+)
} else { } else {
self.weight = players.map { $0.computedRank }.reduce(0,+) self.weight = players.map { $0.computedRank }.reduce(0,+)
@ -185,7 +181,7 @@ class FileImportManager {
static let FFT_ASSIMILATION_WOMAN_IN_MAN = "A calculer selon la pondération en vigueur" static let FFT_ASSIMILATION_WOMAN_IN_MAN = "A calculer selon la pondération en vigueur"
func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool, chunkByParameter: Bool) async throws -> [TeamHolder] { func createTeams(from fileContent: String, tournament: Tournament, fileProvider: FileProvider = .frenchFederation, checkingCategoryDisabled: Bool, chunkMode: ChunkMode) async throws -> [TeamHolder] {
switch fileProvider { switch fileProvider {
case .frenchFederation: case .frenchFederation:
@ -193,9 +189,9 @@ class FileImportManager {
case .padelClub: case .padelClub:
return await _getPadelClubTeams(from: fileContent, tournament: tournament) return await _getPadelClubTeams(from: fileContent, tournament: tournament)
case .custom: case .custom:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: false, tournament: tournament) return await _getPadelBusinessLeagueTeams(from: fileContent, chunkMode: chunkMode, autoSearch: false, tournament: tournament)
case .customAutoSearch: case .customAutoSearch:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: true, tournament: tournament) return await _getPadelBusinessLeagueTeams(from: fileContent, chunkMode: chunkMode, autoSearch: true, tournament: tournament)
} }
} }
@ -309,14 +305,12 @@ class FileImportManager {
if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled { if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled {
let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown) let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament) playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown) let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament) playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 }) let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false { if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament) let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team) results.append(team)
} }
} }
@ -372,14 +366,12 @@ class FileImportManager {
let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown) let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament) playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown) let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament) playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 }) let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false { if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament) let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team) results.append(team)
} }
} }
@ -410,7 +402,6 @@ class FileImportManager {
let registeredPlayers = found?.map({ importedPlayer in let registeredPlayers = found?.map({ importedPlayer in
let player = PlayerRegistration(importedPlayer: importedPlayer) let player = PlayerRegistration(importedPlayer: importedPlayer)
player.setComputedRank(in: tournament) player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
return player return player
}) })
if let registeredPlayers, registeredPlayers.isEmpty == false { if let registeredPlayers, registeredPlayers.isEmpty == false {
@ -428,7 +419,7 @@ class FileImportManager {
return nil return nil
} }
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament) let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
results.append(team) results.append(team)
} }
} }
@ -436,7 +427,7 @@ class FileImportManager {
return results return results
} }
private func _getPadelBusinessLeagueTeams(from fileContent: String, chunkByParameter: Bool, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] { private func _getPadelBusinessLeagueTeams(from fileContent: String, chunkMode: ChunkMode, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
let lines = fileContent.replacingOccurrences(of: "\"", with: "").components(separatedBy: "\n") let lines = fileContent.replacingOccurrences(of: "\"", with: "").components(separatedBy: "\n")
guard let firstLine = lines.first else { return [] } guard let firstLine = lines.first else { return [] }
var separator = "," var separator = ","
@ -447,18 +438,32 @@ class FileImportManager {
let federalContext = PersistenceController.shared.localContainer.viewContext let federalContext = PersistenceController.shared.localContainer.viewContext
var chunks: [[String]] = [] var chunks: [[String]] = []
if chunkByParameter { switch chunkMode {
case .byParameter:
chunks = lines.chunked(byParameterAt: 1) chunks = lines.chunked(byParameterAt: 1)
} else { case .byCoupleOfLines:
chunks = lines.chunked(into: 2) chunks = lines.chunked(into: 2)
case .byColumn:
chunks = lines.extractPlayers(filterKey: tournament.championshipImportKey.capitalized, separator: separator)
} }
let results = chunks.map { team in
let results = chunks.map { teamSource in
var teamName: String? = nil var teamName: String? = nil
var teamChampionship: TeamChampionship? = nil
var team = teamSource
if chunkMode == .byColumn {
if let first = teamSource.first?.components(separatedBy: separator) {
team = Array(teamSource.dropFirst())
teamChampionship = TeamChampionship(registrationDate: first[0], registrationMail: first[1], clubCode: first[2], teamIndex: first[3], clubName: first[4])
}
}
let players = team.map { player in let players = team.map { player in
let data = player.components(separatedBy: separator) let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? "" let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? "" let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerSexType = data[safe: 0] == "f" ? PlayerSexType.female : PlayerSexType.male let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
if data[safe: 1]?.trimmed != nil { if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed teamName = data[safe: 1]?.trimmed
} }
@ -467,28 +472,84 @@ class FileImportManager {
let rank : Int? = data[safe: 6]?.trimmed.toInt() let rank : Int? = data[safe: 6]?.trimmed.toInt()
let licenceId : String? = data[safe: 7]?.prefixTrimmed(50) let licenceId : String? = data[safe: 7]?.prefixTrimmed(50)
let club : String? = data[safe: 8]?.prefixTrimmed(200) let club : String? = data[safe: 8]?.prefixTrimmed(200)
let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName) let status : String? = data[safe: 9]
fetchRequest.predicate = predicate let verified : String? = data[safe: 10]
let found = try? federalContext.fetch(fetchRequest).first let isVerified = verified == "ok"
if let found, autoSearch { let isEQVerified = verified == "ok2"
let player = PlayerRegistration(importedPlayer: found) if chunkMode == .byColumn {
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament) if let licenceId = licenceId?.strippedLicense {
player.email = email let predicate = NSPredicate(format: "license == %@", licenceId)
player.phoneNumber = phoneNumber fetchRequest.predicate = predicate
return player let found = try? federalContext.fetch(fetchRequest).first
if let found {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament)
player.sourceName = lastName
player.isNveq = status == "NVEQ"
player.clubCode = found.clubCode
if isEQVerified {
player.source = .frenchFederationEQVerified
} else if isVerified {
player.source = .frenchFederationVerified
}
return player
} else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
player.sourceName = lastName
player.isNveq = status == "NVEQ"
if isEQVerified {
player.source = .frenchFederationEQVerified
} else if isVerified {
player.source = .frenchFederationVerified
}
if rank == nil {
player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
}
return player
}
} else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
player.sourceName = lastName
player.isNveq = status == "NVEQ"
if isEQVerified {
player.source = .frenchFederationEQVerified
} else if isVerified {
player.source = .frenchFederationVerified
}
if rank == nil {
player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
}
return player
}
} else { } else {
let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email) let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName)
if rank == nil, autoSearch { fetchRequest.predicate = predicate
let found = try? federalContext.fetch(fetchRequest).first
if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament) player.setComputedRank(in: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player
} else { } else {
player.computedRank = rank ?? 0 let player = PlayerRegistration(firstName: firstName, lastName: lastName, licenceId: licenceId, rank: rank, sex: sex, clubName: club, phoneNumber: phoneNumber, email: email)
if rank == nil, autoSearch {
player.setComputedRank(in: tournament)
} else {
player.computedRank = rank ?? 0
}
return player
} }
return player
} }
} }
return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: nil, name: teamName, tournament: tournament) return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: nil, teamChampionship: teamChampionship, tournament: tournament)
} }
return results return results
} }
@ -525,5 +586,292 @@ extension Array where Element == String {
return groups.map { $0.value } return groups.map { $0.value }
} }
} }
func extractPlayers(filterKey: String = "Messieurs", separator: String = ";") -> [[String]] {
return self.dropFirst().compactMap { line in
let components = line.components(separatedBy: separator)
guard components.count >= 62 else { return nil }
guard components.contains(filterKey) else { return nil }
var players: [PlayerChampionship] = []
let teamChampionship = TeamChampionship(registrationDate: components[0], registrationMail: components[1], clubCode: components[3], teamIndex: components[5], clubName: components[2])
// Add captain and coach first
// players.append(PlayerChampionship.captain(components))
// players.append(PlayerChampionship.coach(components))
// Extract team information
let teamType = components[4]
let sex = teamType.lowercased().contains("dames") ? "f" : "m"
// Process up to 10 players
let count = 6
for i in 0..<10 {
let lastNameIndex = 12 + (i * count)
let firstNameIndex = 13 + (i * count)
let validationStatusIndex = 14 + (i * count)
let licenseIndex = 15 + (i * count)
let rankingIndex = 16 + (i * count)
let statusIndex = 17 + (i * count)
guard lastNameIndex < components.count,
!components[lastNameIndex].isEmpty else {
continue
}
let statusString = components[statusIndex]
let status: PlayerChampionship.Status = statusString.hasPrefix("NVEQ") ? .nveq : .eq
var licenseNumber = components[licenseIndex]
//var ranking = components[rankingIndex]
let strippedLicense = components[licenseIndex].strippedLicense
let strippedLicenseRank = components[rankingIndex].strippedLicense
if strippedLicense == nil && strippedLicenseRank != nil {
licenseNumber = components[rankingIndex]
//ranking = components[licenseIndex]
}
let player = PlayerChampionship(
lastName: components[lastNameIndex],
firstName: components[firstNameIndex],
licenseNumber: licenseNumber,
ranking: nil,
status: status,
email: nil,
mobileNumber: nil,
validationStatus: components[validationStatusIndex]
)
players.append(player)
}
return [teamChampionship.rawValue(separator: separator)] + players.map { player in
player.rawValue(separator: separator, sex: sex)
}
}
}
} }
struct TeamChampionship {
let registrationDate: String
let registrationMail: String
let clubCode: String
let teamIndex: String
let clubName: String
func getRegistrationDate() -> Date? {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
if let date = dateFormatter.date(from: registrationDate) {
return date
} else {
return nil
}
}
func rawValue(separator: String = ";") -> String {
let components = [
registrationDate,
registrationMail,
clubCode.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).trimmed,
teamIndex.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).trimmed,
clubName.trimmed
]
return components.joined(separator: separator)
}
}
struct PlayerChampionship {
enum Status: String {
case eq = "EQ" // était licencié au club l'an dernier
case nveq = "NVEQ" // N'a pas joué avec le club l'an dernier
case captain = "CAPTAIN"
case coach = "COACH"
}
let lastName: String
let firstName: String
let licenseNumber: String
let ranking: Int?
let status: Status
let email: String?
let mobileNumber: String?
let validationStatus: String?
static func captain(_ components: [String]) -> PlayerChampionship {
let fullName = components[6].components(separatedBy: " ")
let lastName = fullName.count > 1 ? fullName[0] : components[6]
let firstName = fullName.count > 1 ? fullName[1] : ""
return PlayerChampionship(
lastName: lastName,
firstName: firstName,
licenseNumber: "",
ranking: 0,
status: .captain,
email: components[8],
mobileNumber: components[7],
validationStatus: nil
)
}
static func coach(_ components: [String]) -> PlayerChampionship {
let fullName = components[9].components(separatedBy: " ")
let lastName = fullName.count > 1 ? fullName[0] : components[9]
let firstName = fullName.count > 1 ? fullName[1] : ""
return PlayerChampionship(
lastName: lastName,
firstName: firstName,
licenseNumber: "",
ranking: 0,
status: .coach,
email: components[11],
mobileNumber: components[10],
validationStatus: nil
)
}
func rawValue(separator: String = ";", sex: String = "m") -> String {
let components = [
sex,
"",
lastName.trimmed,
firstName.trimmed,
"",
"",
"",
licenseNumber.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines),
"",
status.rawValue,
validationStatus ?? ""
]
return components.joined(separator: separator)
}
}
enum ChunkMode: Int, Identifiable, CaseIterable {
var id: Int { self.rawValue }
case byParameter
case byCoupleOfLines
case byColumn
func localizedChunkModeLabel() -> String {
switch self {
case .byParameter:
return "Nom d'équipe"
case .byCoupleOfLines:
return "Groupe de 2 lignes"
case .byColumn:
return "Par colonne"
}
}
}
enum ChampionshipAlert: LocalizedError {
case clubCodeInvalid(TeamRegistration)
case tooManyNVEQ(TeamRegistration)
case tooManyPlayers(TeamRegistration)
case playerClubInvalid(PlayerRegistration)
case playerLicenseInvalid(PlayerRegistration)
case playerNameInvalid(PlayerRegistration)
case unranked(PlayerRegistration)
case playerAgeInvalid(PlayerRegistration)
case playerSexInvalid(PlayerRegistration)
case duplicate(Int, PlayerRegistration)
case notEQ(Bool?, PlayerRegistration)
case isYearValid(Bool?, PlayerRegistration)
var errorDescription: String? {
switch self {
case .isYearValid(_, let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .notEQ(_, let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .duplicate(_, let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .clubCodeInvalid(let teamRegistration):
if let clubCode = teamRegistration.clubCode {
return "CODE NOK : \(clubCode)"
} else {
return "aucun code club"
}
case .tooManyNVEQ:
return "TOO MANY NVEQ"
case .tooManyPlayers:
return "TOO MANY PLAYERS"
case .playerClubInvalid(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .playerLicenseInvalid(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .playerNameInvalid(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .unranked(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .playerAgeInvalid(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
case .playerSexInvalid(let playerRegistration):
return playerRegistration.errorDescription(championshipAlert: self)
}
}
}
extension PlayerRegistration {
func errorDescription(championshipAlert: ChampionshipAlert) -> String? {
var message = self.playerLabel() + " - " + self.formattedLicense() + " -> "
switch championshipAlert {
case .isYearValid(let bool, _):
if bool == nil {
message += "MILLESIME INVERIFIABLE"
} else {
message += "MILLESIME INVALIDE"
}
case .notEQ(let bool, _):
if bool == nil {
message += "EQ INVERIFIABLE"
} else {
message += "EQ INVALIDE"
}
case .duplicate(let count, _):
message += "DOUBLON : " + count.formatted()
case .clubCodeInvalid, .tooManyNVEQ, .tooManyPlayers:
return nil
case .unranked:
message += "NC NOT FOUND"
case .playerClubInvalid:
if let clubCode {
message += "CLUB NOK : " + clubCode
} else {
message += "aucun club"
}
case .playerLicenseInvalid:
if licenceId != nil {
message += "LICENSE MISSTYPE"
} else {
message += "aucune licence"
}
case .playerNameInvalid:
if let sourceName {
message += "NOM NOK : " + sourceName
} else {
message += "aucun nom source"
}
case .playerAgeInvalid:
if let computedAge {
message += "AGE NOK : \(computedAge)" + " ans"
} else {
message += "aucun âge"
}
case .playerSexInvalid:
message += "SEXE NOK : " + (isMalePlayer() ? "H" : "F")
}
return message
}
}

@ -9,7 +9,6 @@ import Foundation
import UIKit import UIKit
import WebKit import WebKit
import PDFKit import PDFKit
import PadelClubData
class HtmlGenerator: ObservableObject { class HtmlGenerator: ObservableObject {
@ -25,10 +24,6 @@ class HtmlGenerator: ObservableObject {
@Published var displayHeads: Bool = false @Published var displayHeads: Bool = false
@Published var groupStageIsReady: Bool = false @Published var groupStageIsReady: Bool = false
@Published var displayRank: Bool = false @Published var displayRank: Bool = false
@Published var displayTeamIndex: Bool = false
@Published var displayScore: Bool = false
@Published var displayPlannedDate: Bool = true
private var pdfDocument: PDFDocument = PDFDocument() private var pdfDocument: PDFDocument = PDFDocument()
private var rects: [CGRect] = [] private var rects: [CGRect] = []
private var completionHandler: ((Result<Bool, Error>) -> ())? private var completionHandler: ((Result<Bool, Error>) -> ())?
@ -63,8 +58,6 @@ class HtmlGenerator: ObservableObject {
func generateWebView(webView: WKWebView) { func generateWebView(webView: WKWebView) {
self.webView = webView self.webView = webView
#if targetEnvironment(simulator)
#else
self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in self.webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
print("evaluateJavaScript", "readystage", complete, error) print("evaluateJavaScript", "readystage", complete, error)
if complete != nil { if complete != nil {
@ -81,12 +74,9 @@ class HtmlGenerator: ObservableObject {
}) })
} }
}) })
#endif
} }
func generateGroupStage(webView: WKWebView) { func generateGroupStage(webView: WKWebView) {
#if targetEnvironment(simulator)
#else
webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in webView.evaluateJavaScript("document.readyState", completionHandler: { (complete, error) in
if complete != nil { if complete != nil {
webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in webView.evaluateJavaScript("document.documentElement.scrollHeight", completionHandler: { (height, error) in
@ -121,7 +111,7 @@ class HtmlGenerator: ObservableObject {
}) })
} }
}) })
#endif
} }
func buildPDF() { func buildPDF() {
@ -155,8 +145,6 @@ class HtmlGenerator: ObservableObject {
} }
func createPage() { func createPage() {
#if targetEnvironment(simulator)
#else
let config = WKPDFConfiguration() let config = WKPDFConfiguration()
config.rect = rects[pdfDocument.pageCount] config.rect = rects[pdfDocument.pageCount]
webView.createPDF(configuration: config){ result in webView.createPDF(configuration: config){ result in
@ -175,21 +163,16 @@ class HtmlGenerator: ObservableObject {
self.completionHandler?(.failure(error)) self.completionHandler?(.failure(error))
} }
} }
#endif
} }
func generateHtml() -> String { func generateHtml() -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html() //HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.template(tournament: tournament).html(options: options) HtmlService.template(tournament: tournament).html(headName: displayHeads, withRank: displayRank, withScore: false)
} }
func generateLoserBracketHtml(upperRound: Round) -> String { func generateLoserBracketHtml(upperRound: Round) -> String {
//HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html() //HtmlService.groupstage(bracket: tournament.orderedBrackets.first!).html()
HtmlService.loserBracket(upperRound: upperRound, hideTitle: false).html(options: options) HtmlService.loserBracket(upperRound: upperRound).html(headName: displayHeads, withRank: displayRank, withScore: false)
}
var options: HtmlOptions {
HtmlOptions(headName: displayHeads, withRank: displayRank, withTeamIndex: displayTeamIndex, withScore: displayScore, withPlannedDate: displayPlannedDate, includeLoserBracket: includeLoserBracket)
} }
var pdfURL: URL? { var pdfURL: URL? {

@ -6,39 +6,12 @@
// //
import Foundation import Foundation
import PadelClubData
struct HtmlOptions {
let headName: Bool
let withRank: Bool
let withTeamIndex: Bool
let withScore: Bool
let withPlannedDate: Bool
let includeLoserBracket: Bool
// Default initializer with all options defaulting to true
init(
headName: Bool = true,
withRank: Bool = true,
withTeamIndex: Bool = true,
withScore: Bool = true,
withPlannedDate: Bool = true,
includeLoserBracket: Bool = false
) {
self.headName = headName
self.withRank = withRank
self.withTeamIndex = withTeamIndex
self.withScore = withScore
self.withPlannedDate = withPlannedDate
self.includeLoserBracket = includeLoserBracket
}
}
enum HtmlService { enum HtmlService {
case template(tournament: Tournament) case template(tournament: Tournament)
case bracket(round: Round) case bracket(round: Round)
case loserBracket(upperRound: Round, hideTitle: Bool) case loserBracket(upperRound: Round)
case match(match: Match) case match(match: Match)
case player(entrant: TeamRegistration) case player(entrant: TeamRegistration)
case hiddenPlayer case hiddenPlayer
@ -77,7 +50,7 @@ enum HtmlService {
} }
} }
func html(options: HtmlOptions = HtmlOptions()) -> String { func html(headName: Bool, withRank: Bool, withScore: Bool) -> String {
guard let file = Bundle.main.path(forResource: self.fileName, ofType: "html") else { guard let file = Bundle.main.path(forResource: self.fileName, ofType: "html") else {
fatalError() fatalError()
} }
@ -96,12 +69,12 @@ enum HtmlService {
} }
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: bracket.tournamentObject()!.tournamentTitle(.short)) template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: bracket.tournamentObject()!.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{bracketTitle}}", with: bracket.groupStageTitle()) template = template.replacingOccurrences(of: "{{bracketTitle}}", with: bracket.groupStageTitle())
template = template.replacingOccurrences(of: "{{formatLabel}}", with: bracket.matchFormat.formatTitle())
var col = "" var col = ""
var row = "" var row = ""
bracket.teams().forEach { entrant in bracket.teams().forEach { entrant in
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(options: options)) col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(headName: headName, withRank: withRank, withScore: withScore))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(options: options)) row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).html(headName: headName, withRank: withRank, withScore: withScore))
} }
template = template.replacingOccurrences(of: "{{teamsCol}}", with: col) template = template.replacingOccurrences(of: "{{teamsCol}}", with: col)
template = template.replacingOccurrences(of: "{{teamsRow}}", with: row) template = template.replacingOccurrences(of: "{{teamsRow}}", with: row)
@ -109,15 +82,9 @@ enum HtmlService {
return template return template
case .groupstageEntrant(let entrant): case .groupstageEntrant(let entrant):
var template = html var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.seedIndex() ?? "")
}
if let playerOne = entrant.players()[safe: 0] { if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel()) template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank { if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))") template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else { } else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "") template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -129,7 +96,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] { if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel()) template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank { if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))") template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else { } else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "") template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -141,7 +108,7 @@ enum HtmlService {
return template return template
case .groupstageRow(let entrant, let teamsPerBracket): case .groupstageRow(let entrant, let teamsPerBracket):
var template = html var template = html
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(options: options)) template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageColumn(entrant: entrant, position: "row").html(headName: headName, withRank: withRank, withScore: withScore))
var scores = "" var scores = ""
(0..<teamsPerBracket).forEach { index in (0..<teamsPerBracket).forEach { index in
@ -150,38 +117,31 @@ enum HtmlService {
if shouldHide == false { if shouldHide == false {
match = entrant.groupStageObject()?.matchPlayed(by: entrant.groupStagePosition!, againstPosition: index) match = entrant.groupStageObject()?.matchPlayed(by: entrant.groupStagePosition!, againstPosition: index)
} }
scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(options: options)) scores.append(HtmlService.groupstageScore(score: match, shouldHide: shouldHide).html(headName: headName, withRank: withRank, withScore: withScore))
} }
template = template.replacingOccurrences(of: "{{scores}}", with: scores) template = template.replacingOccurrences(of: "{{scores}}", with: scores)
return template return template
case .groupstageColumn(let entrant, let position): case .groupstageColumn(let entrant, let position):
var template = html var template = html
template = template.replacingOccurrences(of: "{{tablePosition}}", with: position) template = template.replacingOccurrences(of: "{{tablePosition}}", with: position)
template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(options: options)) template = template.replacingOccurrences(of: "{{team}}", with: HtmlService.groupstageEntrant(entrant: entrant).html(headName: headName, withRank: withRank, withScore: withScore))
return template return template
case .groupstageScore(let match, let shouldHide): case .groupstageScore(let match, let shouldHide):
var template = html var template = html
if match == nil || options.withScore == false { if match == nil || withScore == false {
template = template.replacingOccurrences(of: "{{winner}}", with: "") template = template.replacingOccurrences(of: "{{winner}}", with: "")
template = template.replacingOccurrences(of: "{{score}}", with: "") template = template.replacingOccurrences(of: "{{score}}", with: "")
} else if let match, let winner = match.winner() { } else {
template = template.replacingOccurrences(of: "{{winner}}", with: winner.teamLabel()) template = template.replacingOccurrences(of: "{{winner}}", with: match!.winner()!.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match.scoreLabel()) template = template.replacingOccurrences(of: "{{score}}", with: match!.scoreLabel())
} }
template = template.replacingOccurrences(of: "{{hide}}", with: shouldHide ? "hide" : "") template = template.replacingOccurrences(of: "{{hide}}", with: shouldHide ? "hide" : "")
return template return template
case .player(let entrant): case .player(let entrant):
var template = html var template = html
if options.withTeamIndex == false {
template = template.replacingOccurrences(of: #"<div class="player">{{teamIndex}}</div>"#, with: "")
} else {
template = template.replacingOccurrences(of: "{{teamIndex}}", with: entrant.formattedSeed())
}
if let playerOne = entrant.players()[safe: 0] { if let playerOne = entrant.players()[safe: 0] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel()) template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank { if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))") template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else { } else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "") template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -193,7 +153,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] { if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel()) template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank { if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))") template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else { } else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "") template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -204,33 +164,18 @@ enum HtmlService {
} }
return template return template
case .hiddenPlayer: case .hiddenPlayer:
var template = html + html return html + html
if options.withTeamIndex {
template += html
}
return template
case .match(let match): case .match(let match):
var template = html var template = html
if options.withPlannedDate, let plannedStartDate = match.plannedStartDate {
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: plannedStartDate.localizedDate())
} else {
}
if let entrantOne = match.team(.one) { if let entrantOne = match.team(.one) {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(options: options)) template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(headName: headName, withRank: withRank, withScore: withScore))
if options.withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: [top.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
} else { } else {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(options: options)) template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
} }
if let entrantTwo = match.team(.two) { if let entrantTwo = match.team(.two) {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(options: options)) template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withScore: withScore))
if options.withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: [bottom.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
} else { } else {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(options: options)) template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.hiddenPlayer.html(headName: headName, withRank: withRank, withScore: withScore))
} }
if match.disabled { if match.disabled {
template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden") template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden")
@ -243,45 +188,27 @@ enum HtmlService {
} else if match.teamWon(atPosition: .two) == true { } else if match.teamWon(atPosition: .two) == true {
template = template.replacingOccurrences(of: "{{entrantTwoWon}}", with: "winner") template = template.replacingOccurrences(of: "{{entrantTwoWon}}", with: "winner")
} }
// template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n")) template = template.replacingOccurrences(of: "{{matchDescription}}", with: [match.localizedStartDate(), match.scoreLabel()].joined(separator: "\n"))
} }
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: "") template = template.replacingOccurrences(of: "{{matchDescription}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: "")
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: "")
return template return template
case .bracket(let round): case .bracket(let round):
var template = "" var template = ""
var bracket = "" var bracket = ""
for (_, match) in round._matches().enumerated() { for (_, match) in round._matches().enumerated() {
template = template.appending(HtmlService.match(match: match).html(options: options)) template = template.appending(HtmlService.match(match: match).html(headName: headName, withRank: withRank, withScore: withScore))
} }
bracket = html.replacingOccurrences(of: "{{match-template}}", with: template) bracket = html.replacingOccurrences(of: "{{match-template}}", with: template)
bracket = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle()) bracket = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle())
bracket = bracket.replacingOccurrences(of: "{{formatLabel}}", with: round.matchFormat.formatTitle())
return bracket return bracket
case .loserBracket(let upperRound, let hideTitle): case .loserBracket(let upperRound):
var template = html var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: upperRound.correspondingLoserRoundTitle()) template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: upperRound.correspondingLoserRoundTitle())
if let tournamentStartDate = upperRound.initialStartDate()?.localizedDate() {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournamentStartDate)
} else {
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: "")
}
template = template.replacingOccurrences(of: "{{titleHidden}}", with: hideTitle ? "hidden" : "")
var brackets = "" var brackets = ""
for round in upperRound.loserRounds() { for round in upperRound.loserRounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options)) brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
} }
let winnerName = "" var winnerName = ""
let winner = """ let winner = """
<ul class="round" scope="last"> <ul class="round" scope="last">
<li class="spacer">&nbsp;</li> <li class="spacer">&nbsp;</li>
@ -294,35 +221,18 @@ enum HtmlService {
brackets = brackets.appending(winner) brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets) template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
for round in upperRound.loserRounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
return template return template
case .template(let tournament): case .template(let tournament):
var template = html var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156") template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.title))
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournament.formattedDate())
var brackets = "" var brackets = ""
for round in tournament.rounds() { for round in tournament.rounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options)) brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
if options.includeLoserBracket {
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
} }
var winnerName = "" var winnerName = ""
if let tournamentWinner = tournament.tournamentWinner() { if let tournamentWinner = tournament.tournamentWinner() {
winnerName = HtmlService.player(entrant: tournamentWinner).html(options: options) winnerName = HtmlService.player(entrant: tournamentWinner).html(headName: headName, withRank: withRank, withScore: withScore)
} }
let winner = """ let winner = """
<ul class="round" scope="last"> <ul class="round" scope="last">
@ -336,16 +246,6 @@ enum HtmlService {
brackets = brackets.appending(winner) brackets = brackets.appending(winner)
template = template.replacingOccurrences(of: "{{brackets}}", with: brackets) template = template.replacingOccurrences(of: "{{brackets}}", with: brackets)
if options.includeLoserBracket {
for round in tournament.rounds() {
if round.index > 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
}
return template return template
} }
} }

@ -37,8 +37,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func requestLocation() { func requestLocation() {
lastError = nil lastError = nil
manager.requestLocation() manager.requestLocation()
city = nil
location = nil
requestStarted = true requestStarted = true
} }

@ -1,90 +0,0 @@
//
// ConfigurationService.swift
// PadelClub
//
// Created by razmig on 14/04/2025.
//
import Foundation
import LeStorage
class ConfigurationService {
static func fetchTournamentConfig() async throws -> TimeToConfirmConfig {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "config/tournament/", method: .get, requiresToken: true)
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try JSONDecoder().decode(TimeToConfirmConfig.self, from: data)
}
static func fetchPaymentConfig() async throws -> PaymentConfig {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "config/payment/", method: .get, requiresToken: true)
let (data, _) = try await URLSession.shared.data(for: urlRequest)
return try JSONDecoder().decode(PaymentConfig.self, from: data)
}
}
struct TimeToConfirmConfig: Codable {
let timeProximityRules: [String: Int]
let waitingListRules: [String: Int]
let businessRules: BusinessRules
let minimumResponseTime: Int
private enum CodingKeys: String, CodingKey {
case timeProximityRules = "time_proximity_rules"
case waitingListRules = "waiting_list_rules"
case businessRules = "business_rules"
case minimumResponseTime = "minimum_response_time"
}
// Default configuration
static let defaultConfig = TimeToConfirmConfig(
timeProximityRules: [
"24": 30, // within 24h 30 min
"48": 60, // within 48h 60 min
"72": 120, // within 72h 120 min
"default": 240
],
waitingListRules: [
"30": 30, // 30+ teams 30 min
"20": 60, // 20+ teams 60 min
"10": 120, // 10+ teams 120 min
"default": 240
],
businessRules: BusinessRules(
hours: Hours(
start: 8,
end: 21
)
),
minimumResponseTime: 30
)
}
struct BusinessRules: Codable {
let hours: Hours
}
struct Hours: Codable {
let start: Int
let end: Int
private enum CodingKeys: String, CodingKey {
case start
case end
}
}
struct PaymentConfig: Codable {
let stripeFee: Double
// Default configuration
static let defaultConfig = PaymentConfig(stripeFee: 0.0075)
private enum CodingKeys: String, CodingKey {
case stripeFee = "stripe_fee"
}
}

@ -1,302 +0,0 @@
//
// FederalDataService.swift
// PadelClub
//
// Created by Razmig Sarkissian on 09/07/2025.
//
import Foundation
import CoreLocation
import LeStorage
import PadelClubData
struct UmpireContactInfo: Codable {
let name: String?
let email: String?
let phone: String?
}
/// Response model for the batch umpire data endpoint
struct UmpireDataResponse: Codable {
let results: [String: UmpireContactInfo]
}
// New struct for the response from get_fft_club_tournaments and get_fft_all_tournaments
struct TournamentsAPIResponse: Codable {
let success: Bool
let tournaments: [FederalTournament]
let totalResults: Int
let currentCount: Int
let pagesScraped: Int? // Optional, as it might not always be present or relevant
let page: Int? // Optional, as it might not always be present or relevant
let umpireDataIncluded: Bool? // Only for get_fft_club_tournaments_with_umpire_data
let message: String
private enum CodingKeys: String, CodingKey {
case success
case tournaments
case totalResults = "total_results"
case currentCount = "current_count"
case pagesScraped = "pages_scraped"
case page
case umpireDataIncluded = "umpire_data_included"
case message
}
}
// MARK: - FederalDataService
/// `FederalDataService` handles all API calls related to federal data (clubs, tournaments, umpire info).
/// All direct interactions with `tenup.fft.fr` are now assumed to be handled by your backend.
class FederalDataService {
static let shared: FederalDataService = FederalDataService()
// The 'formId', 'tenupJsonDecoder', 'runTenupTask', and 'getNewBuildForm'
// from the legacy NetworkFederalService are removed as their logic is now
// handled server-side.
/// Fetches federal clubs based on geographic criteria.
/// - Parameters:
/// - country: The country code (e.g., "fr").
/// - city: The city name or address for search.
/// - radius: The search radius in kilometers.
/// - location: Optional `CLLocation` for user's precise position to calculate distance.
/// - Returns: A `FederalClubResponse` object containing a list of clubs and total count.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "country", value: country),
URLQueryItem(name: "city", value: city),
URLQueryItem(name: "radius", value: String(Int(radius)))
]
if let location = location {
queryItems.append(URLQueryItem(name: "lat", value: location.coordinate.latitude.formatted(.number.locale(Locale(identifier: "us")))))
queryItems.append(URLQueryItem(name: "lng", value: location.coordinate.longitude.formatted(.number.locale(Locale(identifier: "us")))))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for federal clubs: 'fft/federal-clubs/'
let urlRequest = try service._baseRequest(servicePath: "fft/federal-clubs?\(queryString)", method: .get, requiresToken: false)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse) // Keep URLError for generic network issues
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
return try JSONDecoder().decode(FederalClubResponse.self, from: data)
} catch {
print("Decoding error for FederalClubResponse: \(error)")
// Map decoding error to a generic API error
throw NetworkManagerError.apiError("Failed to decode FederalClubResponse: \(error.localizedDescription)")
}
}
/// Fetches federal tournaments for a specific club.
/// This function now calls your backend, which in turn handles the `form_build_id` and pagination.
/// The `tournaments` parameter is maintained for signature compatibility but is not used for server-side fetching.
/// Client-side accumulation of results from multiple pages should be handled by the caller.
/// - Parameters:
/// - page: The current page number for pagination.
/// - tournaments: An array of already gathered tournaments (for signature compatibility; not used internally for fetching).
/// - club: The name of the club.
/// - codeClub: The unique code of the club.
/// - startDate: Optional start date for filtering tournaments.
/// - endDate: Optional end date for filtering tournaments.
/// - Returns: An array of `FederalTournament` objects for the requested page.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> TournamentsAPIResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "club_code", value: codeClub),
URLQueryItem(name: "club_name", value: club),
URLQueryItem(name: "page", value: String(page))
]
if let startDate = startDate {
queryItems.append(URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted))
}
if let endDate = endDate {
queryItems.append(URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for club tournaments: 'fft/club-tournaments/'
let urlRequest = try service._baseRequest(servicePath: "fft/club-tournaments?\(queryString)", method: .get, requiresToken: false)
print(urlRequest.url?.absoluteString)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
// Your backend should return a direct array of FederalTournament for the requested page
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data)
return federalTournaments
} catch {
print("Decoding error for FederalTournament array: \(error)")
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)")
}
}
/// Fetches all federal tournaments based on various filtering options.
/// This function now calls your backend, which handles the complex filtering and data retrieval.
/// The return type `[HttpCommand]` is maintained for signature compatibility,
/// wrapping the actual `[FederalTournament]` data within an `HttpCommand` structure.
/// - Parameters:
/// - sortingOption: How to sort the results (e.g., "dateDebut asc").
/// - page: The current page number for pagination.
/// - startDate: The start date for the tournament search.
/// - endDate: The end date for the tournament search.
/// - city: The city to search within.
/// - distance: The search distance from the city.
/// - categories: An array of `TournamentCategory` to filter by.
/// - levels: An array of `TournamentLevel` to filter by.
/// - lat: Optional latitude for precise location search.
/// - lng: Optional longitude for precise location search.
/// - ages: An array of `FederalTournamentAge` to filter by.
/// - types: An array of `FederalTournamentType` to filter by.
/// - nationalCup: A boolean indicating if national cup tournaments should be included.
/// - Returns: An array of `HttpCommand` objects, containing the `FederalTournament` data.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getAllFederalTournaments(
sortingOption: String,
page: Int,
startDate: Date,
endDate: Date,
city: String,
distance: Double,
categories: [TournamentCategory],
levels: [TournamentLevel],
lat: String?,
lng: String?,
ages: [FederalTournamentAge],
types: [FederalTournamentType],
nationalCup: Bool
) async throws -> TournamentsAPIResponse {
let service = try StoreCenter.main.service()
// Construct query parameters for your backend API
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "sort", value: sortingOption),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "start_date", value: startDate.twoDigitsYearFormatted),
URLQueryItem(name: "end_date", value: endDate.twoDigitsYearFormatted),
URLQueryItem(name: "city", value: city),
URLQueryItem(name: "distance", value: String(Int(distance))),
URLQueryItem(name: "national_cup", value: nationalCup ? "true" : "false")
]
if let lat = lat, !lat.isEmpty {
queryItems.append(URLQueryItem(name: "lat", value: lat))
}
if let lng = lng, !lng.isEmpty {
queryItems.append(URLQueryItem(name: "lng", value: lng))
}
// Add array parameters (assuming your backend can handle comma-separated or multiple query params)
if !categories.isEmpty {
queryItems.append(URLQueryItem(name: "categories", value: categories.map { String($0.rawValue) }.joined(separator: ",")))
}
if !levels.isEmpty {
queryItems.append(URLQueryItem(name: "levels", value: levels.map { String($0.rawValue) }.joined(separator: ",")))
}
if !ages.isEmpty {
queryItems.append(URLQueryItem(name: "ages", value: ages.map { String($0.rawValue) }.joined(separator: ",")))
}
if !types.isEmpty {
queryItems.append(URLQueryItem(name: "types", value: types.map { $0.rawValue }.joined(separator: ",")))
}
// Build the URL with query parameters
var urlComponents = URLComponents()
urlComponents.queryItems = queryItems
let queryString = urlComponents.query ?? ""
// The servicePath now points to your backend's endpoint for all tournaments: 'fft/all-tournaments/'
var urlRequest = try service._baseRequest(servicePath: "fft/all-tournaments?\(queryString)", method: .get, requiresToken: true)
urlRequest.timeoutInterval = 180
let (data, response) = try await URLSession.shared.data(for: urlRequest)
print(urlRequest.url?.absoluteString ?? "No URL")
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
// Your backend should return a direct array of FederalTournament
let federalTournaments = try JSONDecoder().decode(TournamentsAPIResponse.self, from: data)
return federalTournaments
} catch {
print("Decoding error for FederalTournament array in getAllFederalTournaments: \(error)")
throw NetworkManagerError.apiError("Failed to decode FederalTournament array: \(error.localizedDescription)")
}
}
/// Fetches umpire contact data for a given tournament ID.
/// This function now calls your backend, which performs the HTML scraping.
/// The return type is maintained for signature compatibility, mapping `UmpireContactInfo` to a tuple.
/// - Parameter idTournament: The ID of the tournament.
/// - Returns: A tuple `(name: String?, email: String?, phone: String?)` containing the umpire's contact info.
/// - Throws: An error if the network request fails or decoding the response is unsuccessful.
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) {
let service = try StoreCenter.main.service()
// The servicePath now points to your backend's endpoint for umpire data: 'fft/umpire/{tournament_id}/'
let servicePath = "fft/umpire/\(idTournament)/"
var urlRequest = try service._baseRequest(servicePath: servicePath, method: .get, requiresToken: false)
urlRequest.timeoutInterval = 120.0
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw URLError(.badServerResponse)
}
guard !data.isEmpty else {
throw NetworkManagerError.noDataReceived
}
do {
let umpireInfo = try JSONDecoder().decode(UmpireContactInfo.self, from: data)
// Map the decoded struct to the tuple required by the legacy signature
print(umpireInfo)
return (name: umpireInfo.name, email: umpireInfo.email, phone: umpireInfo.phone)
} catch {
print("Decoding error for UmpireContactInfo: \(error)")
throw NetworkManagerError.apiError("Failed to decode UmpireContactInfo: \(error.localizedDescription)")
}
}
}

@ -7,7 +7,6 @@
import Foundation import Foundation
import CoreLocation import CoreLocation
import PadelClubData
class NetworkFederalService { class NetworkFederalService {
struct HttpCommand: Decodable { struct HttpCommand: Decodable {
@ -34,40 +33,19 @@ class NetworkFederalService {
return decoder return decoder
}() }()
func runTenupTask<T: Decodable>(request: URLRequest) async throws -> T { func runTenupTask<T:Decodable>(request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request) let task = try await URLSession.shared.data(for: request)
if request.httpMethod == "PUT" {
// Print request info print("tried PUT: \(request.url!)")
print("Request: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")") if let urlResponse = task.1 as? HTTPURLResponse {
print(urlResponse.statusCode)
// Print response status
if let httpResponse = response as? HTTPURLResponse {
print("Status code: \(httpResponse.statusCode)")
}
// Print JSON data before decoding
if let jsonObject = try? JSONSerialization.jsonObject(with: data) {
//print("Response JSON: \(jsonObject)")
} else {
print("Response is not a valid JSON")
// Try to print as string if not JSON
if let stringResponse = String(data: data, encoding: .utf8) {
print("Response as string: \(stringResponse)")
} }
} }
return try tenupJsonDecoder.decode(T.self, from: task.0)
// Now try to decode
do {
return try tenupJsonDecoder.decode(T.self, from: data)
} catch {
print("Decoding error: \(error)")
throw error
}
} }
func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse { func federalClubs(country: String = "fr", city: String, radius: Double, location: CLLocation? = nil) async throws -> FederalClubResponse {
return try await FederalDataService.shared.federalClubs(country: country, city: city, radius: radius, location: location)
/* /*
{ {
"geocoding[country]": "fr", "geocoding[country]": "fr",
@ -93,7 +71,7 @@ class NetworkFederalService {
//"geocoding%5Bcountry%5D=fr&geocoding%5Bville%5D=13%20Avenue%20Emile%20Bodin%2013260%20Cassis&geocoding%5Brayon%5D=15&geocoding%5BuserPosition%5D%5Blat%5D=43.22278594081477&geocoding%5BuserPosition%5D%5Blng%5D=5.556953900769194&geocoding%5BuserPosition%5D%5BshowDistance%5D=true&nombreResultat=0&diplomeEtatOption=false&galaxieOption=false&fauteuilOption=false&tennisSanteOption=false" //"geocoding%5Bcountry%5D=fr&geocoding%5Bville%5D=13%20Avenue%20Emile%20Bodin%2013260%20Cassis&geocoding%5Brayon%5D=15&geocoding%5BuserPosition%5D%5Blat%5D=43.22278594081477&geocoding%5BuserPosition%5D%5Blng%5D=5.556953900769194&geocoding%5BuserPosition%5D%5BshowDistance%5D=true&nombreResultat=0&diplomeEtatOption=false&galaxieOption=false&fauteuilOption=false&tennisSanteOption=false"
let postData = parameters.data(using: .utf8) let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!) var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/clubs/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") request.addValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language") request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding") request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
@ -115,11 +93,165 @@ class NetworkFederalService {
func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> [FederalTournament] { func getClubFederalTournaments(page: Int, tournaments: [FederalTournament], club: String, codeClub: String, startDate: Date? = nil, endDate: Date? = nil) async throws -> [FederalTournament] {
return try await FederalDataService.shared.getClubFederalTournaments(page: page, tournaments: tournaments, club: club, codeClub: codeClub, startDate: startDate, endDate: endDate).tournaments
if formId.isEmpty {
do {
try await getNewBuildForm()
} catch {
print("getClubFederalTournaments", error)
}
}
var dateComponent = ""
if let startDate, let endDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(endDate.endOfMonth.twoDigitsYearFormatted)"
} else if let startDate {
dateComponent = "&date[start]=\(startDate.twoDigitsYearFormatted)&date[end]=\(Calendar.current.date(byAdding: .month, value: 3, to: startDate)!.endOfMonth.twoDigitsYearFormatted)"
}
let parameters = """
recherche_type=club&club[autocomplete][value_container][value_field]=\(codeClub.replaceCharactersFromSet(characterSet: .whitespaces))&club[autocomplete][value_container][label_field]=\(club.replaceCharactersFromSet(characterSet: .whitespaces, replacementString: "+"))&pratique=PADEL\(dateComponent)&page=\(page)&sort=dateDebut+asc&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page
"""
let postData = parameters.data(using: .utf8)
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.httpMethod = "POST"
request.httpBody = postData
let commands : [HttpCommand] = try await runTenupTask(request: request)
if commands.anySatisfy({ $0.command == "alert" }) {
throw NetworkManagerError.maintenance
}
let resultCommand = commands.first(where: { $0.results != nil })
if let gatheredTournaments = resultCommand?.results?.items {
var finalTournaments = tournaments + gatheredTournaments
if let count = resultCommand?.results?.nb_results {
if finalTournaments.count < count {
let newTournaments = try await getClubFederalTournaments(page: page+1, tournaments: finalTournaments, club: club, codeClub: codeClub)
finalTournaments = finalTournaments + newTournaments
}
}
return finalTournaments
}
// do {
// } catch {
// print("getClubFederalTournaments", error)
// }
//
return []
}
func getNewBuildForm() async throws {
var request = URLRequest(url: URL(string: "https://tenup.fft.fr/recherche/tournois")!,timeoutInterval: Double.infinity)
request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.addValue("trailers", forHTTPHeaderField: "TE")
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
if let stringData = String(data: task.0, encoding: .utf8) {
let stringDataFolded = stringData.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines)
let prefix = "form_build_id\"value=\"form-"
var finalData = ""
if let lab = stringDataFolded.matches(of: try! Regex("\(prefix)")).last {
finalData = String(stringDataFolded[lab.range.upperBound...])
}
let suffix = "\"/><inputtype=\"hidden\"name=\"form_id\"value=\"recherche_tournois_form"
if let suff = finalData.firstMatch(of: try! Regex("\(suffix)")) {
finalData = String(finalData[..<suff.range.lowerBound])
}
print(finalData)
formId = "form-\(finalData)"
} else {
print("no data found in html")
}
} }
func getAllFederalTournaments(sortingOption: String, page: Int, startDate: Date, endDate: Date, city: String, distance: Double, categories: [TournamentCategory], levels: [TournamentLevel], lat: String?, lng: String?, ages: [FederalTournamentAge], types: [FederalTournamentType], nationalCup: Bool) async throws -> [HttpCommand] {
var cityParameter = ""
var searchType = "ligue"
if city.trimmed.isEmpty == false {
searchType = "ville"
cityParameter = city
}
var levelsParameter = ""
if levels.isEmpty == false {
levelsParameter = levels.map { "categorie_tournoi[\($0.searchRawValue())]=\($0.searchRawValue())" }.joined(separator: "&") + "&"
}
var categoriesParameter = ""
if categories.isEmpty == false {
categoriesParameter = categories.map { "epreuve[\($0.requestLabel)]=\($0.requestLabel)" }.joined(separator: "&") + "&"
}
var agesParameter = ""
if ages.isEmpty == false {
agesParameter = ages.map { "categorie_age[\($0.rawValue)]=\($0.rawValue)" }.joined(separator: "&") + "&"
}
var typesParameter = ""
if types.isEmpty == false {
typesParameter = types.map { "type[\($0.rawValue.capitalized)]=\($0.rawValue.capitalized)" }.joined(separator: "&") + "&"
}
var npc = ""
if nationalCup {
npc = "&tournoi_npc=1"
}
let parameters = """
recherche_type=\(searchType)&ville%5Bautocomplete%5D%5Bcountry%5D=fr&ville%5Bautocomplete%5D%5Btextfield%5D=&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=\(cityParameter)&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blat_field%5D=\(lat ?? "")&ville%5Bautocomplete%5D%5Bvalue_container%5D%5Blng_field%5D=\(lng ?? "")&ville%5Bdistance%5D%5Bvalue_field%5D=\(Int(distance))&club%5Bautocomplete%5D%5Btextfield%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Bvalue_field%5D=&club%5Bautocomplete%5D%5Bvalue_container%5D%5Blabel_field%5D=&pratique=PADEL&date%5Bstart%5D=\(startDate.twoDigitsYearFormatted)&date%5Bend%5D=\(endDate.twoDigitsYearFormatted)&\(categoriesParameter)\(levelsParameter)\(agesParameter)\(typesParameter)\(npc)&page=\(page)&sort=\(sortingOption)&form_build_id=\(formId)&form_id=recherche_tournois_form&_triggering_element_name=submit_page&_triggering_element_value=Submit+page
"""
let postData = parameters.data(using: .utf8)
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) { var request = URLRequest(url: URL(string: "https://tenup.fft.fr/system/ajax")!,timeoutInterval: Double.infinity)
return try await FederalDataService.shared.getUmpireData(idTournament: idTournament) request.addValue("application/json, text/javascript, */*; q=0.01", forHTTPHeaderField: "Accept")
request.addValue("fr,fr-FR;q=0.8,en-US;q=0.5,en;q=0.3", forHTTPHeaderField: "Accept-Language")
request.addValue("gzip, deflate, br", forHTTPHeaderField: "Accept-Encoding")
request.addValue("application/x-www-form-urlencoded; charset=UTF-8", forHTTPHeaderField: "Content-Type")
request.addValue("XMLHttpRequest", forHTTPHeaderField: "X-Requested-With")
request.addValue("https://tenup.fft.fr", forHTTPHeaderField: "Origin")
request.addValue("keep-alive", forHTTPHeaderField: "Connection")
request.addValue("https://tenup.fft.fr/recherche/tournois", forHTTPHeaderField: "Referer")
request.addValue("empty", forHTTPHeaderField: "Sec-Fetch-Dest")
request.addValue("cors", forHTTPHeaderField: "Sec-Fetch-Mode")
request.addValue("same-origin", forHTTPHeaderField: "Sec-Fetch-Site")
request.httpMethod = "POST"
request.httpBody = postData
return try await runTenupTask(request: request)
} }
} }

@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import PadelClubData
class NetworkManager { class NetworkManager {
static let shared: NetworkManager = NetworkManager() static let shared: NetworkManager = NetworkManager()
@ -52,9 +51,9 @@ class NetworkManager {
let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
let fileURL = URLs.main.extend(path: "static/rankings/\(dateString)") let fileURL = URL(string: "https://xlr.alwaysdata.net/static/rankings/\(dateString)")
var request = URLRequest(url:fileURL) var request = URLRequest(url:fileURL!)
request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition") request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition")
if FileManager.default.fileExists(atPath: destinationFileUrl.path()), let modificationDate = destinationFileUrl.creationDate() { if FileManager.default.fileExists(atPath: destinationFileUrl.path()), let modificationDate = destinationFileUrl.creationDate() {
request.addValue(formatDateForHTTPHeader(modificationDate), forHTTPHeaderField: "If-Modified-Since") request.addValue(formatDateForHTTPHeader(modificationDate), forHTTPHeaderField: "If-Modified-Since")

@ -0,0 +1,28 @@
//
// NetworkManagerError.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
enum NetworkManagerError: LocalizedError {
case maintenance
case fileNotYetAvailable
case mailFailed
case mailNotSent //no network no error
case messageFailed
case messageNotSent //no network no error
case fileNotModified
case fileNotDownloaded(Int)
var errorDescription: String? {
switch self {
case .maintenance:
return "Le site de la FFT est en maintenance"
default:
return String(describing: self)
}
}
}

@ -1,77 +0,0 @@
//
// PaymentService.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/10/2025.
//
import Foundation
import LeStorage
import PadelClubData
class PaymentService {
static func resendPaymentEmail(teamRegistrationId: String) async throws -> SimpleResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(
servicePath: "resend-payment-email/\(teamRegistrationId)/",
method: .post,
requiresToken: true
)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PaymentError.requestFailed
}
return try JSON.decoder.decode(SimpleResponse.self, from: data)
}
static func getPaymentLink(teamRegistrationId: String) async throws -> PaymentLinkResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(
servicePath: "payment-link/\(teamRegistrationId)/",
method: .get,
requiresToken: true
)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw PaymentError.requestFailed
}
// // Debug: Print the raw JSON response
// if let jsonString = String(data: data, encoding: .utf8) {
// print("Raw JSON Response: \(jsonString)")
// }
return try JSON.decoder.decode(PaymentLinkResponse.self, from: data)
}
}
struct PaymentLinkResponse: Codable {
let success: Bool
let paymentLink: String?
let message: String?
enum CodingKeys: String, CodingKey {
case success
case paymentLink
case message
}
}
enum PaymentError: Error {
case requestFailed
case unauthorized
case unknown
}
struct SimpleResponse: Codable {
let success: Bool
let message: String
}

@ -1,50 +0,0 @@
//
// RefundService.swift
// PadelClub
//
// Created by razmig on 11/04/2025.
//
import Foundation
import LeStorage
import PadelClubData
class RefundService {
static func processRefund(teamRegistrationId: String) async throws -> RefundResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "refund-tournament/\(teamRegistrationId)/", method: .post, requiresToken: true)
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 200 else {
throw RefundError.requestFailed
}
let refundResponse = try JSON.decoder.decode(RefundResponse.self, from: data)
return refundResponse
}
}
struct RefundResponse: Codable {
let success: Bool
let message: String
let players: [PlayerRegistration]?
enum CodingKeys: String, CodingKey {
case success
case message
case players
}
}
enum RefundError: Error {
case requestFailed
case unauthorized
case unknown
}
struct RefundResult {
let team: TeamRegistration
let response: Result<RefundResponse, Error>
}

@ -1,207 +0,0 @@
//
// StripeValidationService.swift
// PadelClub
//
// Created by razmig on 12/04/2025.
//
import Foundation
import LeStorage
class StripeValidationService {
// MARK: - Validate Stripe Account
static func validateStripeAccount(accountId: String) async throws -> ValidationResponse {
let service = try StoreCenter.main.service()
var urlRequest = try service._baseRequest(servicePath: "validate-stripe-account/", method: .post, requiresToken: true)
var body: [String: Any] = [:]
body["account_id"] = accountId
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(ValidationResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
// Handle client errors - still decode as ValidationResponse
let errorResponse = try JSONDecoder().decode(ValidationResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
// MARK: - Create Stripe Connect Account
static func createStripeConnectAccount() async throws -> CreateAccountResponse {
let service = try StoreCenter.main.service()
let urlRequest = try service._baseRequest(servicePath: "stripe/create-account/", method: .post, requiresToken: true)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
let errorResponse = try JSONDecoder().decode(CreateAccountResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
// MARK: - Create Stripe Account Link
static func createStripeAccountLink(_ accountId: String? = nil) async throws -> CreateLinkResponse {
let service = try StoreCenter.main.service()
var urlRequest = try service._baseRequest(servicePath: "stripe/create-account-link/", method: .post, requiresToken: true)
var body: [String: Any] = [:]
if let accountId = accountId {
body["account_id"] = accountId
}
urlRequest.httpBody = try JSONSerialization.data(withJSONObject: body)
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw ValidationError.invalidResponse
}
switch httpResponse.statusCode {
case 200...299:
let decodedResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data)
return decodedResponse
case 400, 403, 404:
let errorResponse = try JSONDecoder().decode(CreateLinkResponse.self, from: data)
return errorResponse
default:
throw ValidationError.invalidResponse
}
} catch let error as ValidationError {
throw error
} catch {
throw ValidationError.networkError(error)
}
}
}
// MARK: - Response Models
struct ValidationResponse: Codable {
let valid: Bool
let canProcessPayments: Bool?
let onboardingComplete: Bool?
let needsOnboarding: Bool?
let account: AccountDetails?
let error: String?
enum CodingKeys: String, CodingKey {
case valid
case canProcessPayments = "can_process_payments"
case onboardingComplete = "onboarding_complete"
case needsOnboarding = "needs_onboarding"
case account
case error
}
}
struct AccountDetails: Codable {
let id: String
let chargesEnabled: Bool?
let payoutsEnabled: Bool?
let detailsSubmitted: Bool?
enum CodingKeys: String, CodingKey {
case id
case chargesEnabled = "charges_enabled"
case payoutsEnabled = "payouts_enabled"
case detailsSubmitted = "details_submitted"
}
}
struct CreateAccountResponse: Codable {
let success: Bool
let accountId: String?
let message: String?
let existing: Bool?
let error: String?
enum CodingKeys: String, CodingKey {
case success
case accountId = "account_id"
case message
case existing
case error
}
}
struct CreateLinkResponse: Codable {
let success: Bool
let url: URL?
let accountId: String?
let error: String?
enum CodingKeys: String, CodingKey {
case success
case url
case accountId = "account_id"
case error
}
}
enum ValidationError: Error {
case invalidResponse
case networkError(Error)
case invalidData
case encodingError
case urlNotFound
case accountNotFound
case onlinePaymentNotEnabled
var localizedDescription: String {
switch self {
case .invalidResponse:
return "Réponse du serveur invalide"
case .networkError(let error):
return "Erreur réseau : \(error.localizedDescription)"
case .invalidData:
return "Données reçues invalides"
case .encodingError:
return "Échec de l'encodage des données de la requête"
case .accountNotFound:
return "Le compte n'a pas pu être généré"
case .urlNotFound:
return "Le lien pour utiliser un compte stripe n'a pas pu être généré"
case .onlinePaymentNotEnabled:
return "Le paiement en ligne n'a pas pu être activé pour ce tournoi"
}
}
}

@ -0,0 +1,47 @@
//
// PListReader.swift
// PadelClub
//
// Created by Laurent Morvillier on 06/05/2024.
//
import Foundation
class PListReader {
static func dictionary(plist: String) -> [String: Any]? {
if let plistPath = Bundle.main.path(forResource: plist, ofType: "plist") {
// Read plist file into Data
if let plistData = FileManager.default.contents(atPath: plistPath) {
do {
// Deserialize plist data into a dictionary
if let plistDictionary = try PropertyListSerialization.propertyList(from: plistData, options: [], format: nil) as? [String: Any] {
return plistDictionary
}
} catch {
print("Error reading plist data: \(error)")
}
} else {
print("Failed to read plist file at path: \(plistPath)")
}
} else {
print("Plist file 'Data.plist' not found in bundle")
}
return nil
}
static func readString(plist: String, key: String) -> String? {
if let dictionary = self.dictionary(plist: plist) {
return dictionary[key] as? String
}
return nil
}
static func readBool(plist: String, key: String) -> Bool? {
if let dictionary = self.dictionary(plist: plist) {
return dictionary[key] as? Bool
}
return nil
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,164 @@
//
// Patcher.swift
// PadelClub
//
// Created by Laurent Morvillier on 21/06/2024.
//
import Foundation
import LeStorage
enum PatchError: Error {
case patchError(message: String)
}
enum Patch: String, CaseIterable {
case alexisLeDu
case importDataFromDevToProd
case fixMissingMatches
var id: String {
return "padelclub.app.patch.\(self.rawValue)"
}
}
class Patcher {
static func applyAllWhenApplicable() {
for patch in Patch.allCases {
self.patchIfPossible(patch)
}
}
static func patchIfPossible(_ patch: Patch) {
if UserDefaults.standard.value(forKey: patch.id) == nil {
do {
Logger.log(">>> Patches \(patch.rawValue)...")
try self._applyPatch(patch)
UserDefaults.standard.setValue(true, forKey: patch.id)
} catch {
Logger.error(error)
}
}
}
fileprivate static func _applyPatch(_ patch: Patch) throws {
switch patch {
case .alexisLeDu: self._patchAlexisLeDu()
case .importDataFromDevToProd: try self._importDataFromDev()
case .fixMissingMatches: self._patchMissingMatches()
}
}
fileprivate static func _patchAlexisLeDu() {
guard StoreCenter.main.userId == "94f45ed2-8938-4c32-a4b6-e4525073dd33" else { return }
let clubs = DataStore.shared.clubs
StoreCenter.main.resetApiCalls(collection: clubs)
// clubs.resetApiCalls()
for club in clubs.filter({ $0.creator == "d5060b89-e979-4c19-bf78-e459a6ed5318"}) {
club.creator = StoreCenter.main.userId
clubs.writeChangeAndInsertOnServer(instance: club)
}
}
fileprivate static func _importDataFromDev() throws {
let devServices = Services(url: "https://xlr.alwaysdata.net/roads/")
guard devServices.hasToken() else {
return
}
guard StoreCenter.main.synchronizationApiURL == "https://padelclub.app/roads/" else {
return
}
guard let userId = StoreCenter.main.userId else {
return
}
try StoreCenter.main.migrateToken(devServices)
let myClubs: [Club] = DataStore.shared.clubs.filter { $0.creator == userId }
let clubIds: [String] = myClubs.map { $0.id }
myClubs.forEach { club in
DataStore.shared.clubs.insertIntoCurrentService(item: club)
let courts = DataStore.shared.courts.filter { clubIds.contains($0.club) }
for court in courts {
DataStore.shared.courts.insertIntoCurrentService(item: court)
}
}
DataStore.shared.user.clubs = Array(clubIds)
DataStore.shared.saveUser()
DataStore.shared.events.insertAllIntoCurrentService()
DataStore.shared.tournaments.insertAllIntoCurrentService()
DataStore.shared.dateIntervals.insertAllIntoCurrentService()
for tournament in DataStore.shared.tournaments {
let store = tournament.tournamentStore
Task { // need to wait for the collections to load
try await Task.sleep(until: .now + .seconds(2))
store.teamRegistrations.insertAllIntoCurrentService()
store.rounds.insertAllIntoCurrentService()
store.groupStages.insertAllIntoCurrentService()
store.matches.insertAllIntoCurrentService()
store.playerRegistrations.insertAllIntoCurrentService()
store.teamScores.insertAllIntoCurrentService()
}
}
}
fileprivate static func _patchMissingMatches() {
guard let url = StoreCenter.main.synchronizationApiURL else {
return
}
guard url == "https://padelclub.app/roads/" else {
return
}
let services = Services(url: url)
for tournament in DataStore.shared.tournaments {
let store = tournament.tournamentStore
let identifier = StoreIdentifier(value: tournament.id, parameterName: "tournament")
Task {
do {
// if nothing is online we upload the data
let matches: [Match] = try await services.get(identifier: identifier)
if matches.isEmpty {
store.matches.insertAllIntoCurrentService()
}
let playerRegistrations: [PlayerRegistration] = try await services.get(identifier: identifier)
if playerRegistrations.isEmpty {
store.playerRegistrations.insertAllIntoCurrentService()
}
let teamScores: [TeamScore] = try await services.get(identifier: identifier)
if teamScores.isEmpty {
store.teamScores.insertAllIntoCurrentService()
}
} catch {
Logger.error(error)
}
}
}
}
}

@ -1,59 +0,0 @@
import Foundation
func areFrenchPhoneNumbersSimilar(_ phoneNumber1: String?, _ phoneNumber2: String?) -> Bool {
if phoneNumber1?.canonicalVersion == phoneNumber2?.canonicalVersion {
return true
}
// Helper function to normalize a phone number, now returning an optional String
func normalizePhoneNumber(_ numberString: String?) -> String? {
// 1. Safely unwrap the input string. If it's nil or empty, return nil immediately.
guard let numberString = numberString, !numberString.isEmpty else {
return nil
}
// 2. Remove all non-digit characters
let digitsOnly = numberString.filter(\.isNumber)
// If after filtering, there are no digits, return nil.
guard !digitsOnly.isEmpty else {
return nil
}
// 3. Handle French specific prefixes and extract the relevant part
// We need at least 9 digits to get a meaningful 8-digit comparison from the end
if digitsOnly.count >= 9 {
if digitsOnly.hasPrefix("0") {
return String(digitsOnly.suffix(9))
} else if digitsOnly.hasPrefix("33") {
// Ensure there are enough digits after dropping "33"
if digitsOnly.count >= 11 { // "33" + 9 digits = 11
return String(digitsOnly.dropFirst(2).suffix(9))
} else {
return nil // Not enough digits after dropping "33"
}
} else if digitsOnly.count == 9 { // Case like 612341234
return digitsOnly
} else { // More digits but no 0 or 33 prefix, take the last 9
return String(digitsOnly.suffix(9))
}
}
return nil // If it doesn't fit the expected patterns or is too short
}
// Normalize both phone numbers. If either results in nil, we can't compare.
guard let normalizedNumber1 = normalizePhoneNumber(phoneNumber1),
let normalizedNumber2 = normalizePhoneNumber(phoneNumber2) else {
return false
}
// Ensure both normalized numbers have at least 8 digits before comparing suffixes
guard normalizedNumber1.count >= 8 && normalizedNumber2.count >= 8 else {
return false // One or both numbers are too short to have 8 comparable digits
}
// Compare the last 8 digits
return normalizedNumber1.suffix(8) == normalizedNumber2.suffix(8)
}

@ -0,0 +1,261 @@
//
// SourceFileManager.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/03/2024.
//
import Foundation
import LeStorage
class SourceFileManager {
static let shared = SourceFileManager()
init() {
createDirectoryIfNeeded()
}
let rankingSourceDirectory : URL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "rankings")
func createDirectoryIfNeeded() {
let fileManager = FileManager.default
do {
let directoryURL = rankingSourceDirectory
// Check if the directory exists
if !fileManager.fileExists(atPath: directoryURL.path) {
// Directory does not exist, create it
try fileManager.createDirectory(at: directoryURL, withIntermediateDirectories: true, attributes: nil)
print("Directory created at: \(directoryURL)")
} else {
print("Directory already exists at: \(directoryURL)")
}
} catch {
print("Error: \(error)")
}
}
var lastDataSource: String? {
DataStore.shared.appSettings.lastDataSource
}
func lastDataSourceDate() -> Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
func fetchData() async {
await fetchData(fromDate: Date())
// if let mostRecent = mostRecentDateAvailable, let current = Calendar.current.date(byAdding: .month, value: 1, to: mostRecent), current > mostRecent {
// await fetchData(fromDate: current)
// } else {
// }
}
func _removeAllData(fromDate current: Date) {
let lastStringDate = URL.importDateFormatter.string(from: current)
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"]
files.forEach { fileName in
NetworkManager.shared.removeRankingData(lastDateString: lastStringDate, fileName: fileName)
}
}
func exportToCSV(_ prefix: String = "", players: [FederalPlayer], sourceFileType: SourceFile, date: Date) {
let lastDateString = URL.importDateFormatter.string(from: date)
let dateString = [prefix, "CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].filter({ $0.isEmpty == false }).joined(separator: "-") + "." + "csv"
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)!
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)")
var csvText : String = ""
for player in players {
csvText.append(player.exportToCSV() + "\n")
}
do {
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8)
print("CSV file exported successfully.")
} catch {
print("Error writing CSV file:", error)
Logger.error(error)
}
}
actor SourceFileDownloadTracker {
var _downloadedFileStatus : Int? = nil
func updateIfNecessary(with successState: Int?) {
if successState != nil && (_downloadedFileStatus == nil || _downloadedFileStatus == 0) {
_downloadedFileStatus = successState
}
}
func getDownloadedFileStatus() -> Int? {
return _downloadedFileStatus
}
}
//return nil if no new files
//return 1 if new file to import
//return 0 if new file just to re-calc static data, no need to re-import
@discardableResult
func fetchData(fromDate current: Date) async -> Int? {
let lastStringDate = URL.importDateFormatter.string(from: current)
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"]
let sourceFileDownloadTracker = SourceFileDownloadTracker()
do {
try await withThrowingTaskGroup(of: Void.self) { group in // Mark 1
for file in files {
group.addTask { [sourceFileDownloadTracker] in
let success = try await NetworkManager.shared.downloadRankingData(lastDateString: lastStringDate, fileName: file)
await sourceFileDownloadTracker.updateIfNecessary(with: success)
}
}
try await group.waitForAll()
}
// if current < Date() {
// if let nextCurrent = Calendar.current.date(byAdding: .month, value: 1, to: current) {
// await fetchData(fromDate: nextCurrent)
// }
// }
} catch {
print("downloadRankingData", error)
if mostRecentDateAvailable == nil {
if let previousDate = Calendar.current.date(byAdding: .month, value: -1, to: current) {
await fetchData(fromDate: previousDate)
}
}
}
let downloadedFileStatus = await sourceFileDownloadTracker.getDownloadedFileStatus()
return downloadedFileStatus
}
func getAllFiles(initialDate: String = "08-2022") async {
let dates = monthsBetweenDates(startDateString: initialDate, endDateString: Date().monthYearFormatted)
.compactMap {
URL.importDateFormatter.date(from: $0)
}
.filter { date in
allFiles.contains(where: { $0.dateFromPath == date }) == false
}
try? await dates.concurrentForEach { date in
await self.fetchData(fromDate: date)
}
}
func monthsBetweenDates(startDateString: String, endDateString: String) -> [String] {
let dateFormatter = URL.importDateFormatter
guard let startDate = dateFormatter.date(from: startDateString),
let endDate = dateFormatter.date(from: endDateString) else {
return []
}
var months: [String] = []
var currentDate = startDate
let calendar = Calendar.current
while currentDate <= endDate {
let monthString = dateFormatter.string(from: currentDate)
months.append(monthString)
guard let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: currentDate) else {
break
}
currentDate = nextMonthDate
}
return months
}
func getUnrankValue(forMale: Bool, rankSourceDate: Date?) -> Int? {
let _rankSourceDate = rankSourceDate ?? mostRecentDateAvailable
let urls = allFiles(forMale).filter { $0.dateFromPath == _rankSourceDate }
return urls.compactMap { $0.getUnrankedValue() }.sorted().last
}
var mostRecentDateAvailable: Date? {
allFiles(false).first?.dateFromPath
}
func removeAllFilesFromServer() {
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil)
allFiles.filter { $0.pathExtension == "csv" }.forEach { url in
try? FileManager.default.removeItem(at: url)
}
}
func jsonFiles() -> [URL] {
let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in
url.pathExtension == "json"
})
return allJSONFiles
}
var allFiles: [URL] {
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in
url.pathExtension == "csv"
})
return (allFiles + (Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil) ?? [])).sorted { $0.dateFromPath == $1.dateFromPath ? $0.index < $1.index : $0.dateFromPath > $1.dateFromPath }
}
func allFiles(_ isManPlayer: Bool) -> [URL] {
allFiles.filter({ url in
url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue)
})
}
func allFilesSortedByDate(_ isManPlayer: Bool) -> [URL] {
return allFiles(isManPlayer)
}
static func isDateAfterUrlImportDate(date: Date, dateString: String) -> Bool {
guard let importDate = URL.importDateFormatter.date(from: dateString) else {
return false
}
return importDate.isEarlierThan(date)
}
static func getSortOption() -> [SortOption] {
return SortOption.allCases
}
}
enum SourceFile: String, CaseIterable {
case dames = "DAMES"
case messieurs = "MESSIEURS"
var filesFromServer: [URL] {
let rankingSourceDirectory = SourceFileManager.shared.rankingSourceDirectory
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil)
return allFiles.filter{$0.pathExtension == "csv" && $0.path().contains(rawValue)}
}
func currentURLs(importingDate: Date) -> [URL] {
var files = Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil)?.filter({ url in
url.path().contains(rawValue)
}) ?? []
files.append(contentsOf: filesFromServer)
return files.filter({ $0.dateFromPath == importingDate })
}
var isMan: Bool {
switch self {
case .dames:
return false
default:
return true
}
}
}

@ -6,7 +6,6 @@
// //
import Foundation import Foundation
import PadelClubData
typealias LineIterator = AsyncLineSequence<URL.AsyncBytes>.AsyncIterator typealias LineIterator = AsyncLineSequence<URL.AsyncBytes>.AsyncIterator
struct Line: Identifiable { struct Line: Identifiable {
@ -66,23 +65,27 @@ struct Line: Identifiable {
var assimilation: String? { var assimilation: String? {
data[7] data[7]
} }
var clubCode: String? {
data[10]?.replaceCharactersFromSet(characterSet: .whitespaces)
}
} }
struct CSVParser: AsyncSequence, AsyncIteratorProtocol { struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Line typealias Element = Line
let url: URL private let url: URL
private var lineIterator: LineIterator private var lineIterator: LineIterator
private let separator: Character private let seperator: Character
private let quoteCharacter: Character = "\"" private let quoteCharacter: Character = "\""
private var lineNumber = 0 private var lineNumber = 0
private let date: Date private let date: Date
let maleData: Bool let maleData: Bool
init(url: URL, separator: Character = ";") { init(url: URL, seperator: Character = ";") {
self.date = url.dateFromPath self.date = url.dateFromPath
self.url = url self.url = url
self.separator = separator self.seperator = seperator
self.lineIterator = url.lines.makeAsyncIterator() self.lineIterator = url.lines.makeAsyncIterator()
self.maleData = url.path().contains(SourceFile.messieurs.rawValue) self.maleData = url.path().contains(SourceFile.messieurs.rawValue)
} }
@ -128,7 +131,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
func makeAsyncIterator() -> CSVParser { func makeAsyncIterator() -> CSVParser {
return self return self
} }
private func split(line: String) -> [String?] { private func split(line: String) -> [String?] {
var data = [String?]() var data = [String?]()
var inQuote = false var inQuote = false
@ -140,7 +143,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
inQuote = !inQuote inQuote = !inQuote
continue continue
case separator: case seperator:
if !inQuote { if !inQuote {
data.append(currentString.isEmpty ? nil : currentString) data.append(currentString.isEmpty ? nil : currentString)
currentString = "" currentString = ""
@ -158,63 +161,4 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
return data return data
} }
/// Splits the CSV file into multiple temporary CSV files, each containing `size` lines.
/// Returns an array of new `CSVParser` instances pointing to these chunked files.
func getChunkedParser(size: Int) async throws -> [CSVParser] {
var chunkedParsers: [CSVParser] = []
var currentChunk: [String] = []
var iterator = self.makeAsyncIterator()
var chunkIndex = 0
while let line = try await iterator.next()?.rawValue {
currentChunk.append(line)
// When the chunk reaches the desired size, write it to a file
if currentChunk.count == size {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
chunkIndex += 1
currentChunk.removeAll()
}
}
// Handle remaining lines (if any)
if !currentChunk.isEmpty {
let chunkURL = try writeChunkToFile(chunk: currentChunk, index: chunkIndex)
chunkedParsers.append(CSVParser(url: chunkURL, separator: self.separator))
}
return chunkedParsers
}
/// Writes a chunk of CSV lines to a temporary file and returns its URL.
private func writeChunkToFile(chunk: [String], index: Int) throws -> URL {
let tempDirectory = FileManager.default.temporaryDirectory
let chunkURL = tempDirectory.appendingPathComponent("\(url.lastPathComponent)-\(index).csv")
let chunkData = chunk.joined(separator: "\n")
try chunkData.write(to: chunkURL, atomically: true, encoding: .utf8)
return chunkURL
}
}
/// Process all large CSV files concurrently and gather all mini CSVs.
func chunkAllSources(sources: [CSVParser], size: Int) async throws -> [CSVParser] {
var allChunks: [CSVParser] = []
await withTaskGroup(of: [CSVParser].self) { group in
for source in sources {
group.addTask {
return (try? await source.getChunkedParser(size: size)) ?? []
}
}
for await miniCSVs in group {
allChunks.append(contentsOf: miniCSVs)
}
}
return allChunks
} }

@ -7,7 +7,6 @@
import Foundation import Foundation
import TipKit import TipKit
import PadelClubData
struct PadelBeachExportTip: Tip { struct PadelBeachExportTip: Tip {
var title: Text { var title: Text {
@ -22,7 +21,7 @@ struct PadelBeachExportTip: Tip {
var image: Image? { var image: Image? {
Image(systemName: "square.and.arrow.up") Image(systemName: "square.and.arrow.up")
} }
var actions: [Action] { var actions: [Action] {
Action(id: "more-info-export", title: "En savoir plus") Action(id: "more-info-export", title: "En savoir plus")
Action(id: "beach-padel", title: "beach-padel.app.fft.fr") Action(id: "beach-padel", title: "beach-padel.app.fft.fr")
@ -43,7 +42,7 @@ struct PadelBeachImportTip: Tip {
var image: Image? { var image: Image? {
Image(systemName: "square.and.arrow.down") Image(systemName: "square.and.arrow.down")
} }
var actions: [Action] { var actions: [Action] {
Action(id: "more-info-import", title: "Importer le fichier excel beach-padel") Action(id: "more-info-import", title: "Importer le fichier excel beach-padel")
} }
@ -62,8 +61,8 @@ struct GenerateLoserBracketTip: Tip {
var image: Image? { var image: Image? {
nil nil
} }
var actions: [Action] { var actions: [Action] {
Action(id: "generate-loser-bracket", title: "Générer les matchs de classements") Action(id: "generate-loser-bracket", title: "Générer les matchs de classements")
} }
@ -84,7 +83,7 @@ struct TeamChampionshipTip: Tip {
var image: Image? { var image: Image? {
Image(systemName: "person.3") Image(systemName: "person.3")
} }
var actions: [Action] { var actions: [Action] {
Action(id: "list-manager", title: "Ouvrir le gestionnaire d'équipe") Action(id: "list-manager", title: "Ouvrir le gestionnaire d'équipe")
} }
@ -105,7 +104,7 @@ struct TeamChampionshipMainScreenTip: Tip {
var image: Image? { var image: Image? {
Image(systemName: "arrow.uturn.backward") Image(systemName: "arrow.uturn.backward")
} }
var actions: [Action] { var actions: [Action] {
Action(id: "set-list-manager-main", title: "Afficher sur l'écran principal") Action(id: "set-list-manager-main", title: "Afficher sur l'écran principal")
} }
@ -194,7 +193,7 @@ struct InscriptionManagerWomanRankTip: Tip {
var image: Image? { var image: Image? {
Image(systemName: "figure.dress.line.vertical.figure") Image(systemName: "figure.dress.line.vertical.figure")
} }
var title: Text { var title: Text {
Text("Rang d'une joueuse dans un tournoi messieurs") Text("Rang d'une joueuse dans un tournoi messieurs")
} }
@ -214,7 +213,7 @@ struct InscriptionManagerRankUpdateTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club vous permet de mettre à jour le classement des équipes inscrites. Si vous avez clôturé les inscriptions, la mise à jour du classement ne modifie pas la phase d'intégration de l'équipe, poule ou tableau final. Vous pouvez manuellement mettre à jour cette option.") Text("Padel Club vous permet de mettre à jour le classement des équipes inscrites. Si vous avez clôturé les inscriptions, la mise à jour du classement ne modifie pas la phase d'intégration de l'équipe, poule ou tableau final. Vous pouvez manuellement mettre à jour cette option.")
} }
var image: Image? { var image: Image? {
Image(systemName: "list.number") Image(systemName: "list.number")
} }
@ -233,7 +232,7 @@ struct SharePictureTip: Tip {
var message: Text? { var message: Text? {
Text("Lors d'un partage d'une photo, le texte est disponible dans le presse-papier du téléphone") Text("Lors d'un partage d'une photo, le texte est disponible dans le presse-papier du téléphone")
} }
var image: Image? { var image: Image? {
Image(systemName: "photo.badge.checkmark.fill") Image(systemName: "photo.badge.checkmark.fill")
} }
@ -247,7 +246,7 @@ struct NewRankDataAvailableTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club récupère toutes les données publique provenant de la FFT. L'importation de ce nouveau classement peut prendre plusieurs dizaines de secondes.") Text("Padel Club récupère toutes les données publique provenant de la FFT. L'importation de ce nouveau classement peut prendre plusieurs dizaines de secondes.")
} }
var image: Image? { var image: Image? {
Image(systemName: "exclamationmark.icloud") Image(systemName: "exclamationmark.icloud")
} }
@ -267,7 +266,7 @@ struct ClubSearchTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club peut rechercher un club autour de vous, d'une ville ou d'un code postal, facilitant ainsi la saisie d'information.") Text("Padel Club peut rechercher un club autour de vous, d'une ville ou d'un code postal, facilitant ainsi la saisie d'information.")
} }
var image: Image? { var image: Image? {
Image(systemName: "house.and.flag.fill") Image(systemName: "house.and.flag.fill")
} }
@ -276,7 +275,7 @@ struct ClubSearchTip: Tip {
Action(id: ActionKey.searchAroundMe.rawValue, title: "Chercher autour de moi") Action(id: ActionKey.searchAroundMe.rawValue, title: "Chercher autour de moi")
Action(id: ActionKey.searchCity.rawValue, title: "Chercher une ville") Action(id: ActionKey.searchCity.rawValue, title: "Chercher une ville")
} }
enum ActionKey: String { enum ActionKey: String {
case searchAroundMe = "search-around-me" case searchAroundMe = "search-around-me"
case searchCity = "search-city" case searchCity = "search-city"
@ -292,7 +291,7 @@ struct SlideToDeleteTip: Tip {
var message: Text? { var message: Text? {
Text("Vous pouvez effacer un club en glissant votre doigt vers la gauche") Text("Vous pouvez effacer un club en glissant votre doigt vers la gauche")
} }
var image: Image? { var image: Image? {
Image(systemName: "trash") Image(systemName: "trash")
} }
@ -307,7 +306,7 @@ struct MultiTournamentsEventTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club permet de gérer plusieurs tournois ayant lieu en même temps. Un P100 homme et dame le même week-end par exemple.") Text("Padel Club permet de gérer plusieurs tournois ayant lieu en même temps. Un P100 homme et dame le même week-end par exemple.")
} }
var image: Image? { var image: Image? {
Image(systemName: "trophy.circle") Image(systemName: "trophy.circle")
} }
@ -321,7 +320,7 @@ struct NotFoundAreWalkOutTip: Tip {
var message: Text? { var message: Text? {
Text("Si une équipe déjà présente dans votre liste d'attente n'est pas dans le fichier, elle sera mise WO") Text("Si une équipe déjà présente dans votre liste d'attente n'est pas dans le fichier, elle sera mise WO")
} }
var image: Image? { var image: Image? {
Image(systemName: "person.2.slash.fill") Image(systemName: "person.2.slash.fill")
} }
@ -339,7 +338,7 @@ struct TournamentPublishingTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club vous permet de publier votre tournoi et rendre accessible à tous les résultats des matchs et l'évolution de l'événement. Les informations seront accessibles sur le site Padel Club.") Text("Padel Club vous permet de publier votre tournoi et rendre accessible à tous les résultats des matchs et l'évolution de l'événement. Les informations seront accessibles sur le site Padel Club.")
} }
var image: Image? { var image: Image? {
Image("PadelClub_logo_fondclair_transparent") Image("PadelClub_logo_fondclair_transparent")
} }
@ -353,7 +352,7 @@ struct TournamentTVBroadcastTip: Tip {
var message: Text? { var message: Text? {
return Text("Padel Club vous propose un site spéficique à utiliser sur les écrans de votre club, présentant de manière intelligente l'évolution de votre tournoi.") return Text("Padel Club vous propose un site spéficique à utiliser sur les écrans de votre club, présentant de manière intelligente l'évolution de votre tournoi.")
} }
var image: Image? { var image: Image? {
Image(systemName: "sparkles.tv") Image(systemName: "sparkles.tv")
} }
@ -362,7 +361,7 @@ struct TournamentTVBroadcastTip: Tip {
struct TournamentSelectionTip: Tip { struct TournamentSelectionTip: Tip {
@Parameter @Parameter
static var tournamentCount: Int? = nil static var tournamentCount: Int? = nil
var rules: [Rule] { var rules: [Rule] {
[ [
// Define a rule based on the app state. // Define a rule based on the app state.
@ -380,7 +379,7 @@ struct TournamentSelectionTip: Tip {
var message: Text? { var message: Text? {
return Text("Vous pouvez appuyer sur la barre de navigation pour accéder à un tournoi de votre événement.") return Text("Vous pouvez appuyer sur la barre de navigation pour accéder à un tournoi de votre événement.")
} }
var image: Image? { var image: Image? {
Image(systemName: "filemenu.and.selection") Image(systemName: "filemenu.and.selection")
} }
@ -389,7 +388,7 @@ struct TournamentSelectionTip: Tip {
struct TournamentRunningTip: Tip { struct TournamentRunningTip: Tip {
@Parameter @Parameter
static var isRunning: Bool = false static var isRunning: Bool = false
var rules: [Rule] { var rules: [Rule] {
[ [
// Define a rule based on the app state. // Define a rule based on the app state.
@ -407,7 +406,7 @@ struct TournamentRunningTip: Tip {
var message: Text? { var message: Text? {
return Text("Le tournoi a commencé, les options utiles surtout à sa préparation sont maintenant accessibles dans le menu en haut à droite.") return Text("Le tournoi a commencé, les options utiles surtout à sa préparation sont maintenant accessibles dans le menu en haut à droite.")
} }
var image: Image? { var image: Image? {
Image(systemName: "ellipsis.circle") Image(systemName: "ellipsis.circle")
} }
@ -422,18 +421,18 @@ struct CreateAccountTip: Tip {
let message = "Un compte est nécessaire pour publier le tournoi sur [Padel Club](\(URLs.main.rawValue)) et profiter de toutes les pages du site, comme le mode TV pour transformer l'expérience de vos tournois !" let message = "Un compte est nécessaire pour publier le tournoi sur [Padel Club](\(URLs.main.rawValue)) et profiter de toutes les pages du site, comme le mode TV pour transformer l'expérience de vos tournois !"
return Text(.init(message)) return Text(.init(message))
} }
var image: Image? { var image: Image? {
Image(systemName: "person.crop.circle") Image(systemName: "person.crop.circle")
} }
var actions: [Action] { var actions: [Action] {
Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte") Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte")
//todo //todo
//Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus") //Action(id: ActionKey.learnMore.rawValue, title: "En savoir plus")
Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Voir le site Padel Club") Action(id: ActionKey.accessPadelClubWebPage.rawValue, title: "Voir le site Padel Club")
} }
enum ActionKey: String { enum ActionKey: String {
case createAccount = "createAccount" case createAccount = "createAccount"
case learnMore = "learnMore" case learnMore = "learnMore"
@ -444,7 +443,7 @@ struct CreateAccountTip: Tip {
struct SlideToDeleteSeedTip: Tip { struct SlideToDeleteSeedTip: Tip {
@Parameter @Parameter
static var seeds: Int = 0 static var seeds: Int = 0
var rules: [Rule] { var rules: [Rule] {
[ [
// Define a rule based on the app state. // Define a rule based on the app state.
@ -462,7 +461,7 @@ struct SlideToDeleteSeedTip: Tip {
var message: Text? { var message: Text? {
Text("Vous pouvez retirer une tête de série de sa position en glissant votre doigt vers la gauche") Text("Vous pouvez retirer une tête de série de sa position en glissant votre doigt vers la gauche")
} }
var image: Image? { var image: Image? {
Image(systemName: "person.fill.xmark") Image(systemName: "person.fill.xmark")
} }
@ -471,7 +470,7 @@ struct SlideToDeleteSeedTip: Tip {
struct PrintTip: Tip { struct PrintTip: Tip {
@Parameter @Parameter
static var seeds: Int = 0 static var seeds: Int = 0
var rules: [Rule] { var rules: [Rule] {
[ [
// Define a rule based on the app state. // Define a rule based on the app state.
@ -481,7 +480,7 @@ struct PrintTip: Tip {
} }
] ]
} }
var title: Text { var title: Text {
Text("Coup d'oeil de votre tableau") Text("Coup d'oeil de votre tableau")
} }
@ -489,7 +488,7 @@ struct PrintTip: Tip {
var message: Text? { var message: Text? {
Text("Vous pouvez avoir un aperçu de votre tableau ou l'imprimer.") Text("Vous pouvez avoir un aperçu de votre tableau ou l'imprimer.")
} }
var image: Image? { var image: Image? {
Image(systemName: "printer") Image(systemName: "printer")
} }
@ -506,9 +505,9 @@ struct PrintTip: Tip {
struct BracketEditTip: Tip { struct BracketEditTip: Tip {
@Parameter @Parameter
static var matchesHidden: Int = 0 static var matchesHidden: Int = 0
var nextRoundName: String? var nextRoundName: String?
var rules: [Rule] { var rules: [Rule] {
[ [
// Define a rule based on the app state. // Define a rule based on the app state.
@ -529,14 +528,14 @@ struct BracketEditTip: Tip {
let wording = nextRoundName != nil ? "en \(nextRoundName!)" : "dans la manche suivante" let wording = nextRoundName != nil ? "en \(nextRoundName!)" : "dans la manche suivante"
return Text("Padel Club a bien pris en compte \(article) tête\(Self.matchesHidden.pluralSuffix) de série positionnée\(Self.matchesHidden.pluralSuffix) \(wording). Le\(Self.matchesHidden.pluralSuffix) \(Self.matchesHidden) match\(Self.matchesHidden.pluralSuffix) inutile\(Self.matchesHidden.pluralSuffix) \(grammar) été désactivé automatiquement.") return Text("Padel Club a bien pris en compte \(article) tête\(Self.matchesHidden.pluralSuffix) de série positionnée\(Self.matchesHidden.pluralSuffix) \(wording). Le\(Self.matchesHidden.pluralSuffix) \(Self.matchesHidden) match\(Self.matchesHidden.pluralSuffix) inutile\(Self.matchesHidden.pluralSuffix) \(grammar) été désactivé automatiquement.")
} }
var image: Image? { var image: Image? {
Image(systemName: "rectangle.slash") Image(systemName: "rectangle.slash")
} }
} }
struct TeamsExportTip: Tip { struct TeamsExportTip: Tip {
var title: Text { var title: Text {
Text("Exporter les paires") Text("Exporter les paires")
} }
@ -544,41 +543,12 @@ struct TeamsExportTip: Tip {
var message: Text? { var message: Text? {
Text("Partager les paires comme indiqué dans le guide de la compétition à J-6 avant midi.") Text("Partager les paires comme indiqué dans le guide de la compétition à J-6 avant midi.")
} }
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
}
struct TimeSlotMoveTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("Vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? {
Image(systemName: "arrow.up.arrow.down.circle")
}
}
struct TimeSlotMoveOptionTip: Tip {
var title: Text {
Text("Réorganisez vos créneaux horaires !")
}
var message: Text? {
Text("En cliquant ici, vous pouvez déplacer les créneaux horaires dans la liste en glissant-déposant.")
}
var image: Image? { var image: Image? {
Image(systemName: "sparkles") Image(systemName: "square.and.arrow.up")
} }
} }
struct PlayerTournamentSearchTip: Tip { struct PlayerTournamentSearchTip: Tip {
var title: Text { var title: Text {
Text("Cherchez un tournoi autour de vous !") Text("Cherchez un tournoi autour de vous !")
@ -587,7 +557,7 @@ struct PlayerTournamentSearchTip: Tip {
var message: Text? { var message: Text? {
Text("Padel Club facilite la recherche de tournois et l'inscription !") Text("Padel Club facilite la recherche de tournois et l'inscription !")
} }
var image: Image? { var image: Image? {
Image(systemName: "trophy.circle") Image(systemName: "trophy.circle")
} }
@ -602,85 +572,12 @@ struct PlayerTournamentSearchTip: Tip {
} }
struct OnlineRegistrationTip: Tip {
var title: Text {
Text("Inscription en ligne")
}
var message: Text? {
Text("Facilitez les inscriptions à votre tournoi en activant l'inscription en ligne. Les joueurs pourront s'inscrire directement depuis l'application ou le site Padel Club.")
}
var image: Image? {
Image(systemName: "person.2.crop.square.stack")
}
var actions: [Action] {
[
Action(id: ActionKey.more.rawValue, title: "En savoir plus"),
Action(id: ActionKey.enableOnlineRegistration.rawValue, title: "Activer dans les réglages du tournoi")
]
}
enum ActionKey: String {
case more = "more"
case enableOnlineRegistration = "enableOnlineRegistration"
}
}
struct ShouldTournamentBeOverTip: Tip {
var title: Text {
Text("Clôturer le tournoi ?")
}
var message: Text? {
Text("Le dernier match est terminé depuis plus de 2 heures. Si le tournoi a été annulé pour cause de météo vous pouvez l'indiquer comme 'Annulé' dans le menu en haut à droite, si ce n'est pas le cas, saisissez les scores manquants pour clôturer automatiquement le tournoi et publier le classement final.")
}
var image: Image? {
Image(systemName: "clock.badge.questionmark")
}
var actions: [Action] {
Action(id: "tournament-status", title: "Gérer le statut du tournoi")
}
}
struct UpdatePlannedDatesTip: Tip {
var title: Text {
Text("Mettre à jour la programmation des matchs")
}
var message: Text? {
Text("Tous les matchs dans le futur verront leur dates plannifiées mis à jour dans la section Programmation du site Padel Club.")
}
var image: Image? {
Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90.circle")
}
}
struct MergeTournamentTip: Tip {
var title: Text {
Text("Transfert de tournois")
}
var message: Text? {
Text("Vous pouvez transferer des tournois d'un autre événement dans celui-ci.")
}
var image: Image? {
Image(systemName: "square.and.arrow.down")
}
}
struct TipStyleModifier: ViewModifier { struct TipStyleModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme @Environment(\.colorScheme) var colorScheme
var tint: Color? var tint: Color?
var background: Color? var background: Color?
var asSection: Bool var asSection: Bool
func body(content: Content) -> some View { func body(content: Content) -> some View {
if asSection { if asSection {
Section { Section {
@ -690,7 +587,7 @@ struct TipStyleModifier: ViewModifier {
preparedContent(content: content) preparedContent(content: content)
} }
} }
@ViewBuilder @ViewBuilder
func preparedContent(content: Content) -> some View { func preparedContent(content: Content) -> some View {
if let background { if let background {

@ -0,0 +1,86 @@
//
// URLs.swift
// PadelClub
//
// Created by Laurent Morvillier on 22/04/2024.
//
import Foundation
enum URLs: String, Identifiable {
#if DEBUG
case activationHost = "xlr.alwaysdata.net"
case main = "https://xlr.alwaysdata.net/"
case api = "https://xlr.alwaysdata.net/roads/"
#elseif TESTFLIGHT
case activationHost = "xlr.alwaysdata.net"
case main = "https://xlr.alwaysdata.net/"
case api = "https://xlr.alwaysdata.net/roads/"
#elseif PRODTEST
case activationHost = "padelclub.app"
case main = "https://padelclub.app/"
case api = "https://padelclub.app/roads/"
#else
case activationHost = "padelclub.app"
case main = "https://padelclub.app/"
case api = "https://padelclub.app/roads/"
#endif
case subscriptions = "https://apple.co/2Th4vqI"
case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/"
//case padelClub = "https://padelclub.app"
case tenup = "https://tenup.fft.fr"
case padelCompetitionGeneralGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi2PB5LeNNTxlrS_1-REGLESGENERALESDELACOMPETITION-ANNEESPORTIVE2025.pdf"
case padelCompetitionSpecificGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi4ax5LeNNTxlsu_3-CAHIERDESCHARGESDESTOURNOIS-ANNEESPORTIVE2025.pdf"
case padelRules = "https://xlr.alwaysdata.net/static/rules/padel-rules-2024.pdf"
case restingDischarge = "https://club.fft.fr/tennisfirmidecazeville/60120370_d/data_1/pdf/fo/formlairededechargederesponsabilitetournoidepadel.pdf"
case appReview = "https://apps.apple.com/app/padel-club/id6484163558?mt=8&action=write-review"
case appDescription = "https://padelclub.app/download/"
case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn"
case appStore = "https://apps.apple.com/app/padel-club/id6484163558"
case eula = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/"
case privacy = "https://padelclub.app/terms-of-use"
var id: String { return self.rawValue }
var url: URL {
return URL(string: self.rawValue)!
}
}
enum PageLink: String, Identifiable, CaseIterable {
case teams = "Équipes"
case summons = "Convocations"
case groupStages = "Poules"
case matches = "Tournoi"
case rankings = "Classement"
case broadcast = "Mode TV (Tournoi)"
case clubBroadcast = "Mode TV (Club)"
var id: String { self.rawValue }
func localizedLabel() -> String {
rawValue
}
var path: String {
switch self {
case .matches:
return ""
case .teams:
return "teams"
case .summons:
return "summons"
case .rankings:
return "rankings"
case .groupStages:
return "group-stages"
case .broadcast:
return "broadcast"
case .clubBroadcast:
return ""
}
}
}

@ -1,33 +0,0 @@
//
// VersionComparator.swift
// PadelClub
//
// Created by Laurent Morvillier on 13/02/2025.
//
class VersionComparator {
static func compare(_ version1: String, _ version2: String) -> Int {
// Split versions into components
let v1Components = version1.split(separator: ".").map { Int($0) ?? 0 }
let v2Components = version2.split(separator: ".").map { Int($0) ?? 0 }
// Get the maximum length to compare
let maxLength = max(v1Components.count, v2Components.count)
// Compare each component
for i in 0..<maxLength {
let v1Num = i < v1Components.count ? v1Components[i] : 0
let v2Num = i < v2Components.count ? v2Components[i] : 0
if v1Num < v2Num {
return -1 // version1 is smaller
} else if v1Num > v2Num {
return 1 // version1 is larger
}
}
return 0 // versions are equal
}
}

@ -8,7 +8,6 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import TipKit import TipKit
import PadelClubData
enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable { enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var id: Int { self.rawValue } var id: Int { self.rawValue }
@ -25,7 +24,7 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var localizedTitleKey: String { var localizedTitleKey: String {
switch self { switch self {
case .activity: case .activity:
return "À venir" return "En cours"
case .history: case .history:
return "Terminé" return "Terminé"
case .tenup: case .tenup:
@ -60,9 +59,9 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
func badgeValue() -> Int? { func badgeValue() -> Int? {
switch self { switch self {
case .activity: case .activity:
DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count DataStore.shared.tournaments.filter { $0.endDate == nil && $0.isDeleted == false && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
case .history: case .history:
DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) && $0.sharing != .granted }.count DataStore.shared.tournaments.filter { $0.endDate != nil && FederalDataViewModel.shared.isTournamentValidForFilters($0) }.count
case .tenup: case .tenup:
FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+) FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+)
case .around: case .around:

@ -7,7 +7,6 @@
import SwiftUI import SwiftUI
import LeStorage import LeStorage
import PadelClubData
@Observable @Observable
class FederalDataViewModel { class FederalDataViewModel {
@ -23,21 +22,19 @@ class FederalDataViewModel {
var searchAttemptCount: Int = 0 var searchAttemptCount: Int = 0
var dayDuration: Int? var dayDuration: Int?
var dayPeriod: DayPeriod = .all var dayPeriod: DayPeriod = .all
var weekdays: Set<Int> = Set()
var lastError: NetworkManagerError? var lastError: NetworkManagerError?
func filterStatus() -> String { func filterStatus() -> String {
var labels: [String] = [] var labels: [String] = []
labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList()) labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList())
labels.append(contentsOf: categories.map { $0.localizedCategoryLabel() }.formatList()) labels.append(contentsOf: categories.map { $0.localizedLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedFederalAgeLabel() }.formatList()) labels.append(contentsOf: ageCategories.map { $0.localizedLabel() }.formatList())
let clubNames = selectedClubs.compactMap { codeClub in let clubNames = selectedClubs.compactMap { codeClub in
let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub }) let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub })
return club?.clubTitle(.short) return club?.clubTitle(.short)
} }
labels.append(contentsOf: clubNames.formatList()) labels.append(contentsOf: clubNames.formatList())
labels.append(contentsOf: weekdays.map { Date.weekdays[$0 - 1] }.formatList())
if dayPeriod != .all { if dayPeriod != .all {
labels.append(dayPeriod.localizedDayPeriodLabel()) labels.append(dayPeriod.localizedDayPeriodLabel())
} }
@ -70,12 +67,11 @@ class FederalDataViewModel {
selectedClubs.removeAll() selectedClubs.removeAll()
dayPeriod = .all dayPeriod = .all
dayDuration = nil dayDuration = nil
weekdays.removeAll()
id = UUID() id = UUID()
} }
func areFiltersEnabled() -> Bool { func areFiltersEnabled() -> Bool {
(weekdays.isEmpty && levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false (levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false
} }
var filteredFederalTournaments: [FederalTournamentHolder] { var filteredFederalTournaments: [FederalTournamentHolder] {
@ -94,25 +90,21 @@ class FederalDataViewModel {
&& &&
(ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) })) (ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) }))
&& &&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!))) (selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
&& &&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod)) (dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&& &&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration)) (dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
}) })
} }
func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int { func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int {
tournaments.filter({ tournament in tournaments.filter({ tournament in
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!))) (selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
&& &&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod)) (dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&& &&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration)) (dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
}) })
.flatMap { $0.tournaments } .flatMap { $0.tournaments }
.filter { .filter {
@ -144,8 +136,6 @@ class FederalDataViewModel {
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod)) (dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&& &&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration)) (dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
if let codeClub = tournament.club()?.code { if let codeClub = tournament.club()?.code {
return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub)) return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub))
@ -161,52 +151,21 @@ class FederalDataViewModel {
&& &&
(ageCategories.isEmpty || ageCategories.contains(build.age)) (ageCategories.isEmpty || ageCategories.contains(build.age))
&& &&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!))) (selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
&& &&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod)) (dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&& &&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration)) (dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
} }
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws { func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
// Use actor or lock to safely collect results from concurrent operations
actor TournamentCollector {
private var federalTournaments: [FederalTournament] = []
func add(tournaments: [FederalTournament]) {
self.federalTournaments.append(contentsOf: tournaments)
}
func getAllTournaments() -> [FederalTournament] {
return federalTournaments
}
}
let collector = TournamentCollector()
try await clubs.filter { $0.code != nil }.concurrentForEach { club in try await clubs.filter { $0.code != nil }.concurrentForEach { club in
let newTournaments = try await FederalDataService.shared.getClubFederalTournaments( let newTournaments = try await NetworkFederalService.shared.getClubFederalTournaments(page: 0, tournaments: [], club: club.name, codeClub: club.code!, startDate: startDate, endDate: endDate)
page: 0,
tournaments: [],
club: club.name,
codeClub: club.code!,
startDate: startDate,
endDate: endDate
)
// Safely add to collector newTournaments.forEach { tournament in
await collector.add(tournaments: newTournaments.tournaments) if self.federalTournaments.contains(where: { $0.id == tournament.id }) == false {
} self.federalTournaments.append(tournament)
}
// Get all collected tournaments
let allNewTournaments = await collector.getAllTournaments()
// Now safely update the main array with unique items
for tournament in allNewTournaments {
if !self.federalTournaments.contains(where: { $0.id == tournament.id }) {
self.federalTournaments.append(tournament)
} }
} }
} }

@ -0,0 +1,154 @@
//
// swift
// PadelClub
//
// Created by Razmig Sarkissian on 02/04/2024.
//
import Foundation
import SwiftUI
class MatchDescriptor: ObservableObject {
@Published var matchFormat: MatchFormat
@Published var setDescriptors: [SetDescriptor]
var court: Int = 1
var title: String = "Titre du match"
var teamLabelOne: String = ""
var teamLabelTwo: String = ""
var startDate: Date = Date()
var match: Match?
let colorTeamOne: Color = .teal
let colorTeamTwo: Color = .indigo
var teamOneSetupIsActive: Bool {
if hasEnded && showSetInputView == false && showTieBreakInputView == false {
return false
}
guard let setDescriptor = setDescriptors.last else {
return false
}
if setDescriptor.valueTeamOne == nil {
return true
} else if setDescriptor.valueTeamTwo == nil {
return false
} else if setDescriptor.tieBreakValueTeamOne == nil, setDescriptor.shouldTieBreak {
return true
} else if setDescriptor.tieBreakValueTeamTwo == nil, setDescriptor.shouldTieBreak {
return false
}
return false
}
var teamTwoSetupIsActive: Bool {
if hasEnded && showSetInputView == false && showTieBreakInputView == false {
return false
}
guard let setDescriptor = setDescriptors.last else {
return false
}
if setDescriptor.valueTeamOne == nil {
return false
} else if setDescriptor.valueTeamTwo == nil {
return true
} else if setDescriptor.tieBreakValueTeamOne == nil, setDescriptor.shouldTieBreak {
return false
} else if setDescriptor.tieBreakValueTeamTwo == nil, setDescriptor.shouldTieBreak {
return true
}
return true
}
var showSetInputView: Bool {
return setDescriptors.anySatisfy({ $0.showSetInputView })
}
var showTieBreakInputView: Bool {
return setDescriptors.anySatisfy({ $0.showTieBreakInputView })
}
init(match: Match? = nil) {
self.match = match
if let groupStage = match?.groupStageObject {
self.matchFormat = groupStage.matchFormat
self.setDescriptors = [SetDescriptor(setFormat: groupStage.matchFormat.setFormat)]
} else {
let format = match?.matchFormat ?? match?.currentTournament()?.matchFormat ?? .defaultFormatForMatchType(.groupStage)
self.matchFormat = format
self.setDescriptors = [SetDescriptor(setFormat: format.setFormat)]
}
let teamOne = match?.team(.one)
let teamTwo = match?.team(.two)
self.teamLabelOne = teamOne?.teamLabel(.wide, twoLines: true) ?? ""
self.teamLabelTwo = teamTwo?.teamLabel(.wide, twoLines: true) ?? ""
if let match, let scoresTeamOne = match.teamScore(ofTeam: teamOne)?.score, let scoresTeamTwo = match.teamScore(ofTeam: teamTwo)?.score {
self.setDescriptors = combineArraysIntoTuples(scoresTeamOne.components(separatedBy: ","), scoresTeamTwo.components(separatedBy: ",")).map({ (a:String?, b:String?) in
SetDescriptor(valueTeamOne: a != nil ? Int(a!) : nil, valueTeamTwo: b != nil ? Int(b!) : nil, setFormat: match.matchFormat.setFormat)
})
}
}
var teamOneScores: [String] {
setDescriptors.compactMap { $0.getValue(teamPosition: .one) }
}
var teamTwoScores: [String] {
setDescriptors.compactMap { $0.getValue(teamPosition: .two) }
}
var scoreTeamOne: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .one }.count }
var scoreTeamTwo: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .two }.count }
var hasEnded: Bool {
return matchFormat.hasEnded(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo)
}
func addNewSet() {
if hasEnded == false {
setDescriptors.append(SetDescriptor(setFormat: matchFormat.newSetFormat(setCount: setDescriptors.count)))
}
}
var winner: TeamPosition {
matchFormat.winner(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo)
}
var winnerLabel: String {
if winner == .one {
return teamLabelOne
} else {
return teamLabelTwo
}
}
}
fileprivate func combineArraysIntoTuples(_ array1: [String], _ array2: [String]) -> [(String?, String?)] {
// Zip the two arrays together and map them to tuples of optional strings
let combined = zip(array1, array2).map { (element1, element2) in
return (element1, element2)
}
// If one array is longer than the other, append the remaining elements
let remainingElements: [(String?, String?)]
if array1.count > array2.count {
let remaining = Array(array1[array2.count...]).map { (element) in
return (element, nil as String?)
}
remainingElements = remaining
} else if array2.count > array1.count {
let remaining = Array(array2[array1.count...]).map { (element) in
return (nil as String?, element)
}
remainingElements = remaining
} else {
remainingElements = []
}
// Concatenate the two arrays
return combined + remainingElements
}

@ -6,13 +6,12 @@
// //
import SwiftUI import SwiftUI
import PadelClubData
@Observable @Observable
class NavigationViewModel { class NavigationViewModel {
var path = NavigationPath() var path = NavigationPath()
var toolboxPath = NavigationPath() var toolboxPath = NavigationPath()
var accountPath: [MyAccountView.AccountScreen] = [] var umpirePath: [UmpireView.UmpireScreen] = []
var ongoingPath = NavigationPath() var ongoingPath = NavigationPath()
var selectedTab: TabDestination? var selectedTab: TabDestination?
var agendaDestination: AgendaDestination? = .activity var agendaDestination: AgendaDestination? = .activity

@ -0,0 +1,24 @@
//
// Screen.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
enum Screen: String, Codable {
case inscription
case groupStage
case round
case settings
case structure
case schedule
case cashier
case call
case rankings
case broadcast
case event
case print
case restingTime
}

@ -6,7 +6,6 @@
// //
import SwiftUI import SwiftUI
import PadelClubData
class DebouncableViewModel: ObservableObject { class DebouncableViewModel: ObservableObject {
@Published var debouncableText: String = "" @Published var debouncableText: String = ""
@ -15,13 +14,13 @@ class DebouncableViewModel: ObservableObject {
class SearchViewModel: ObservableObject, Identifiable { class SearchViewModel: ObservableObject, Identifiable {
let id: UUID = UUID() let id: UUID = UUID()
var allowSelection: Int = 0 var allowSelection : Int = 0
var codeClub: String? = nil var codeClub: String? = nil
var clubName: String? = nil var clubName: String? = nil
var ligueName: String? = nil var ligueName: String? = nil
var showFemaleInMaleAssimilation: Bool = false var showFemaleInMaleAssimilation: Bool = false
var hidePlayers: [String]? var hidePlayers: [String]?
@Published var debouncableText: String = "" @Published var debouncableText: String = ""
@Published var searchText: String = "" @Published var searchText: String = ""
@Published var task: DispatchWorkItem? @Published var task: DispatchWorkItem?
@ -38,113 +37,68 @@ class SearchViewModel: ObservableObject, Identifiable {
@Published var isPresented: Bool = false @Published var isPresented: Bool = false
@Published var selectedAgeCategory: FederalTournamentAge = .unlisted @Published var selectedAgeCategory: FederalTournamentAge = .unlisted
@Published var mostRecentDate: Date? = nil @Published var mostRecentDate: Date? = nil
var selectionIsOver: Bool { var selectionIsOver: Bool {
if allowSingleSelection && selectedPlayers.count == 1 { if allowSingleSelection && selectedPlayers.count == 1 {
return true return true
} else if allowMultipleSelection && selectedPlayers.count == allowSelection { } else if allowMultipleSelection && selectedPlayers.count == allowSelection {
return true return true
} }
return false return false
} }
var allowMultipleSelection: Bool { var allowMultipleSelection: Bool {
allowSelection > 1 || allowSelection == -1 allowSelection > 1 || allowSelection == -1
} }
var allowSingleSelection: Bool { var allowSingleSelection: Bool {
allowSelection == 1 allowSelection == 1
} }
var debounceTrigger: Double { var debounceTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.4 : 0.1 (dataSet == .national || dataSet == .ligue) ? 0.4 : 0.1
} }
var throttleTrigger: Double { var throttleTrigger: Double {
(dataSet == .national || dataSet == .ligue) ? 0.15 : 0.1 (dataSet == .national || dataSet == .ligue) ? 0.15 : 0.1
} }
var contentUnavailableMessage: String { var contentUnavailableMessage: String {
var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."] var message = ["Vérifiez l'ortographe ou lancez une nouvelle recherche."]
if tokens.isEmpty { if tokens.isEmpty {
message.append( message.append("Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver.")
"Il est possible que cette personne n'est joué aucun tournoi depuis les 12 derniers mois, dans ce cas, Padel Club ne pourra pas le trouver."
)
} }
return message.joined(separator: "\n") return message.joined(separator: "\n")
} }
func sortTitle() -> String {
var base = [sortOption.localizedLabel()]
base.append((ascending ? "croissant" : "décroissant"))
if selectedAgeCategory != .unlisted {
base.append(selectedAgeCategory.localizedFederalAgeLabel())
}
return base.joined(separator: " ")
}
func codeClubs() -> [String] { func codeClubs() -> [String] {
let clubs: [Club] = DataStore.shared.user.clubsObjects() let clubs: [Club] = DataStore.shared.user.clubsObjects()
return clubs.compactMap { $0.code } return clubs.compactMap { $0.code }
} }
func getCodeClub() -> String? { func getCodeClub() -> String? {
if let codeClub { return codeClub } if let codeClub { return codeClub }
if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode { if let userCodeClub = DataStore.shared.user.currentPlayerData()?.clubCode { return userCodeClub }
return userCodeClub
}
return nil return nil
} }
func getLigueName() -> String? { func getLigueName() -> String? {
if let ligueName { return ligueName } if let ligueName { return ligueName }
if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName { if let userLigueName = DataStore.shared.user.currentPlayerData()?.ligueName { return userLigueName }
return userLigueName
}
return nil return nil
} }
func shouldIncludeSearchTextPredicate() -> Bool {
if allowMultipleSelection {
return true
}
if allowSingleSelection {
return true
}
if tokens.isEmpty == false || hideAssimilation || selectedAgeCategory != .unlisted {
return true
}
return dataSet == .national && searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
}
func showIndex() -> Bool { func showIndex() -> Bool {
if dataSet == .national { if (dataSet == .national || dataSet == .ligue) { return isFiltering() }
if searchText.isEmpty == false
&& (tokens.isEmpty == true && hideAssimilation == false
&& selectedAgeCategory == .unlisted)
{
return false
} else {
return isFiltering()
}
}
if dataSet == .ligue { return isFiltering() }
if filterOption == .all { return isFiltering() } if filterOption == .all { return isFiltering() }
return true return true
} }
func isFiltering() -> Bool { func isFiltering() -> Bool {
searchText.isEmpty == false || tokens.isEmpty == false || hideAssimilation searchText.isEmpty == false || tokens.isEmpty == false || hideAssimilation || selectedAgeCategory != .unlisted
|| selectedAgeCategory != .unlisted
} }
func prompt(forDataSet: DataSet) -> String { func prompt(forDataSet: DataSet) -> String {
switch forDataSet { switch forDataSet {
case .national: case .national:
@ -161,7 +115,7 @@ class SearchViewModel: ObservableObject, Identifiable {
return "dans mes favoris" return "dans mes favoris"
} }
} }
func label(forDataSet: DataSet) -> String { func label(forDataSet: DataSet) -> String {
switch forDataSet { switch forDataSet {
case .national: case .national:
@ -178,240 +132,119 @@ class SearchViewModel: ObservableObject, Identifiable {
} }
func words() -> [String] { func words() -> [String] {
let cleanedText = searchText.cleanSearchText().canonicalVersionWithPunctuation.trimmed return searchText.canonicalVersionWithPunctuation.trimmed.components(separatedBy: .whitespaces)
return cleanedText.components(
separatedBy: .whitespaces)
} }
func wordsPredicates() -> NSPredicate? { func wordsPredicates() -> NSPredicate? {
let words = words().filter({ $0.isEmpty == false }) let words = words().filter({ $0.isEmpty == false })
// Handle special case of hyphenated words
let hyphenatedWords = searchText.components(separatedBy: .whitespaces)
.filter { $0.contains("-") }
var predicates: [NSPredicate] = []
// Add predicates for hyphenated words
for word in hyphenatedWords {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", word))
let parts = word.components(separatedBy: "-")
for part in parts where part.count > 1 {
predicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
}
}
// Regular words processing
switch words.count { switch words.count {
case 2: case 2:
predicates.append(contentsOf: [ let predicates = [
NSPredicate( NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[0], words[1]),
format: NSPredicate(format: "canonicalLastName beginswith[cd] %@ AND canonicalFirstName beginswith[cd] %@", words[1], words[0]),
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@", ]
words[0], words[1]), return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
NSPredicate(
format:
"canonicalLastName CONTAINS[cd] %@ AND canonicalFirstName CONTAINS[cd] %@",
words[1], words[0]),
// For multi-word first names, try the two words as a first name
NSPredicate(
format: "canonicalFirstName CONTAINS[cd] %@", words.joined(separator: " ")),
])
case 3:
// Handle potential cases like "Jean Christophe CROS"
predicates.append(contentsOf: [
// First two words as first name, last as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0] + " " + words[1], words[2]),
// First as first name, last two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[0], words[1] + " " + words[2]),
// Last as first name, first two as last name
NSPredicate(
format:
"canonicalFirstName CONTAINS[cd] %@ AND canonicalLastName CONTAINS[cd] %@",
words[2], words[0] + " " + words[1]),
])
default: default:
if words.count > 0 {
// For single word or many words, try matching against full name
predicates.append(
NSPredicate(
format: "canonicalFullName CONTAINS[cd] %@",
words.joined(separator: " ")))
}
}
return predicates.isEmpty
? nil : NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
}
func searchTextPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union(
CharacterSet(charactersIn: "-"))
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
// Check for license numbers
let matches = canonicalVersionWithoutPunctuation.licencesFound()
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
predicates.append(NSCompoundPredicate(orPredicateWithSubpredicates: licensesPredicates))
if canonicalVersionWithoutPunctuation.isEmpty == false {
let wordsPredicates = wordsPredicates()
if let wordsPredicates {
predicates.append(wordsPredicates)
}
// Add match for full name
predicates.append(
NSPredicate(
format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
// Add pattern match for more flexible matching
let components = canonicalVersionWithoutPunctuation.split(separator: " ")
let pattern = components.joined(separator: ".*")
predicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
// // Look for exact matches on first or last name
// let words = canonicalVersionWithoutPunctuation.components(separatedBy: .whitespaces)
// for word in words where word.count > 2 {
// predicates.append(
// NSPredicate(
// format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", word, word)
// )
// }
}
if predicates.isEmpty {
return nil return nil
} }
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
} }
func orPredicate() -> NSPredicate? { func orPredicate() -> NSPredicate? {
var predicates: [NSPredicate] = [] var predicates : [NSPredicate] = []
let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces).union( let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
CharacterSet(charactersIn: "-")) let canonicalVersionWithoutPunctuation = searchText.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmed
let canonicalVersionWithoutPunctuation = searchText.canonicalVersion
.components(separatedBy: allowedCharacterSet.inverted)
.joined()
.trimmed
let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed let canonicalVersionWithPunctuation = searchText.canonicalVersionWithPunctuation.trimmed
switch tokens.first {
if tokens.isEmpty { case .none:
if shouldIncludeSearchTextPredicate(), if canonicalVersionWithoutPunctuation.isEmpty == false {
canonicalVersionWithoutPunctuation.isEmpty == false let wordsPredicates = wordsPredicates()
{ if let wordsPredicates {
if let searchTextPredicate = searchTextPredicate() { predicates.append(wordsPredicates)
predicates.append(searchTextPredicate)
}
}
}
// Process tokens
for token in tokens {
switch token {
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(
NSPredicate(
format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation)
)
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(
NSPredicate(
format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(
NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(
NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
} else { } else {
predicates.append( predicates.append(NSPredicate(format: "license contains[cd] %@", canonicalVersionWithoutPunctuation))
NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
case .age:
if canonicalVersionWithoutPunctuation.isEmpty
|| Int(canonicalVersionWithoutPunctuation) == 0
{
predicates.append(NSPredicate(format: "birthYear == 0"))
} else if let birthYear = Int(canonicalVersionWithoutPunctuation) {
predicates.append(
NSPredicate(format: "birthYear == %@", birthYear.formattedAsRawString()))
} }
predicates.append(NSPredicate(format: "canonicalFullName contains[cd] %@", canonicalVersionWithoutPunctuation))
let components = canonicalVersionWithoutPunctuation.split(separator: " ")
let pattern = components.joined(separator: ".*")
let predicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
predicates.append(predicate)
}
case .ligue:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "ligueName == nil"))
} else {
predicates.append(NSPredicate(format: "ligueName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .club:
if canonicalVersionWithoutPunctuation.isEmpty {
predicates.append(NSPredicate(format: "clubName == nil"))
} else {
predicates.append(NSPredicate(format: "clubName contains[cd] %@", canonicalVersionWithoutPunctuation))
}
case .rankMoreThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank >= %@", canonicalVersionWithoutPunctuation))
}
case .rankLessThan:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank <= %@", canonicalVersionWithoutPunctuation))
}
case .rankBetween:
let values = canonicalVersionWithPunctuation.components(separatedBy: ",")
if canonicalVersionWithPunctuation.isEmpty || values.count != 2 {
predicates.append(NSPredicate(format: "rank == 0"))
} else {
predicates.append(NSPredicate(format: "rank BETWEEN {%@,%@}", values.first!, values.last!))
}
case .age:
if canonicalVersionWithoutPunctuation.isEmpty || Int(canonicalVersionWithoutPunctuation) == 0 {
predicates.append(NSPredicate(format: "birthYear == 0"))
} else if let birthYear = Int(canonicalVersionWithoutPunctuation) {
predicates.append(NSPredicate(format: "birthYear == %@", birthYear.formattedAsRawString()))
} }
}
}
if predicates.isEmpty { if predicates.isEmpty {
return nil return nil
} }
return NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
let full = NSCompoundPredicate(orPredicateWithSubpredicates: predicates)
return full
} }
func predicate() -> NSPredicate? { func predicate() -> NSPredicate? {
var predicates: [NSPredicate?] = [ var predicates : [NSPredicate?] = [
orPredicate(), orPredicate(),
filterOption == .male ? NSPredicate(format: "male == YES") : nil, filterOption == .male ?
filterOption == .female ? NSPredicate(format: "male == NO") : nil, NSPredicate(format: "male == YES") :
nil,
filterOption == .female ?
NSPredicate(format: "male == NO") :
nil,
] ]
if let mostRecentDate { if let mostRecentDate {
predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg)) predicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
} }
if hideAssimilation { if hideAssimilation {
predicates.append(NSPredicate(format: "assimilation == %@", "Non")) predicates.append(NSPredicate(format: "assimilation == %@", "Non"))
} }
if selectedAgeCategory != .unlisted { if selectedAgeCategory != .unlisted {
let computedBirthYear = selectedAgeCategory.computedBirthYear() let computedBirthYear = selectedAgeCategory.computedBirthYear()
if let left = computedBirthYear.0 { if let left = computedBirthYear.0 {
predicates.append( predicates.append(NSPredicate(format: "birthYear >= %@", left.formattedAsRawString()))
NSPredicate(format: "birthYear >= %@", left.formattedAsRawString()))
} }
if let right = computedBirthYear.1 { if let right = computedBirthYear.1 {
predicates.append( predicates.append(NSPredicate(format: "birthYear <= %@", right.formattedAsRawString()))
NSPredicate(format: "birthYear <= %@", right.formattedAsRawString()))
} }
} }
switch dataSet { switch dataSet {
case .national: case .national:
break break
@ -437,31 +270,31 @@ class SearchViewModel: ObservableObject, Identifiable {
if hidePlayers?.isEmpty == false { if hidePlayers?.isEmpty == false {
predicates.append(NSPredicate(format: "NOT (license IN %@)", hidePlayers!)) predicates.append(NSPredicate(format: "NOT (license IN %@)", hidePlayers!))
} }
return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 })) return NSCompoundPredicate(andPredicateWithSubpredicates: predicates.compactMap({ $0 }))
} }
func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] { func sortDescriptors() -> [SortDescriptor<ImportedPlayer>] {
sortOption.sortDescriptors(ascending, dataSet: dataSet) sortOption.sortDescriptors(ascending, dataSet: dataSet)
} }
func nsSortDescriptors() -> [NSSortDescriptor] { func nsSortDescriptors() -> [NSSortDescriptor] {
sortDescriptors().map { NSSortDescriptor($0) } sortDescriptors().map { NSSortDescriptor($0) }
} }
static func getSpecialSlashPredicate(inputString: String) -> NSPredicate? { static func getSpecialSlashPredicate(inputString: String) -> NSPredicate? {
// Define a regular expression to find slashes between alphabetic characters (not digits) // Define a regular expression to find slashes between alphabetic characters (not digits)
print(inputString) print(inputString)
let cleanedInput = inputString.cleanSearchText()
let pattern = /(\b[A-Za-z]+)\s*\/\s*([A-Za-z]+\b)/ let pattern = /(\b[A-Za-z]+)\s*\/\s*([A-Za-z]+\b)/
// Find matches in the input string // Find matches in the input string
guard let match = cleanedInput.firstMatch(of: pattern) else { guard let match = inputString.firstMatch(of: pattern) else {
print("No valid name pairs found") print("No valid name pairs found")
return nil return nil
} }
let lastName1 = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines) let lastName1 = match.output.1.trimmingCharacters(in: .whitespacesAndNewlines)
let lastName2 = match.output.2.trimmingCharacters(in: .whitespacesAndNewlines) let lastName2 = match.output.2.trimmingCharacters(in: .whitespacesAndNewlines)
@ -470,167 +303,70 @@ class SearchViewModel: ObservableObject, Identifiable {
print("One or both names are empty") print("One or both names are empty")
return nil return nil
} }
// Create the NSPredicate for searching in the `lastName` field // Create the NSPredicate for searching in the `lastName` field
let predicate = NSPredicate( let predicate = NSPredicate(format: "lastName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", lastName1, lastName2)
format: "lastName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@", lastName1, lastName2)
// Output the result // Output the result
//print("Generated Predicate: \(predicate)") //print("Generated Predicate: \(predicate)")
return predicate return predicate
} }
static func pastePredicate(
pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption static func pastePredicate(pasteField: String, mostRecentDate: Date?, filterOption: PlayerFilterOption) -> NSPredicate? {
) -> NSPredicate? { let allowedCharacterSet = CharacterSet.alphanumerics.union(.whitespaces)
print("🔍 pastePredicate called with: \(pasteField)")
print("📅 mostRecentDate: \(String(describing: mostRecentDate))") // Remove all characters that are not in the allowedCharacterSet
print("🔍 filterOption: \(filterOption)") var text = pasteField.canonicalVersion.components(separatedBy: allowedCharacterSet.inverted).joined().trimmedMultiline
// Define the regex pattern to match digits
let digitPattern = /\d+/
// Replace all occurrences of the pattern (digits) with an empty string
text = text.replacing(digitPattern, with: "")
let textStrings: [String] = text.components(separatedBy: .whitespacesAndNewlines)
let nonEmptyStrings: [String] = textStrings.compactMap { $0.isEmpty ? nil : $0 }
let nameComponents = nonEmptyStrings.filter({ $0 != "de" && $0 != "la" && $0 != "le" && $0.count > 1 })
var andPredicates = [NSPredicate]() var andPredicates = [NSPredicate]()
var orPredicates = [NSPredicate]() var orPredicates = [NSPredicate]()
//self.wordsCount = nameComponents.count
// Check for license numbers if let slashPredicate = getSpecialSlashPredicate(inputString: pasteField) {
let matches = pasteField.licencesFound() orPredicates.append(slashPredicate)
print("🎫 Licenses found: \(matches)")
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
orPredicates = licensesPredicates
if matches.count == 2 {
print("✅ Returning early with 2 license predicates")
return NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)
} }
// Add gender filter if specified
if filterOption == .female { if filterOption == .female {
print("👩 Adding female filter")
andPredicates.append(NSPredicate(format: "male == NO")) andPredicates.append(NSPredicate(format: "male == NO"))
} else if filterOption == .male {
print("👨 Adding male filter")
andPredicates.append(NSPredicate(format: "male == YES"))
} }
// Add date filter if specified
if let mostRecentDate { if let mostRecentDate {
print("📆 Adding date filter for: \(mostRecentDate)")
andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg)) andPredicates.append(NSPredicate(format: "importDate == %@", mostRecentDate as CVarArg))
} }
// Check for slashes (representing alternatives) if nameComponents.count > 1 {
if let slashPredicate = getSpecialSlashPredicate(inputString: pasteField) { orPredicates.append(contentsOf: nameComponents.pairs().map {
print("🔀 Found slash predicate") return NSPredicate(format: "(firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@) OR (firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@)", $0, $1, $1, $0) })
orPredicates.append(slashPredicate) } else {
orPredicates.append(contentsOf: nameComponents.map { NSPredicate(format: "firstName contains[cd] %@ OR lastName contains[cd] %@", $0,$0) })
} }
// Prepare text for processing - preserve hyphens but remove digits
var text =
pasteField
.replacingOccurrences(of: "[\\(\\)\\[\\]\\{\\}]", with: "", options: .regularExpression)
.replacingOccurrences(of: "/", with: " ") // Replace slashes with spaces
.replacingOccurrences(of: "[\\.,;:!?]", with: " ", options: .regularExpression) // Replace common punctuation with spaces
.trimmingCharacters(in: .whitespacesAndNewlines)
print("🧹 Cleaned text: \"\(text)\"") let components = text.split(separator: " ")
let pattern = components.joined(separator: ".*")
// Remove digits print(text, pattern)
let digitPattern = /\b\w*\d\w*\b/ let canonicalFullNamePredicate = NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern)
text = text.replacing(digitPattern, with: "").trimmingCharacters( orPredicates.append(canonicalFullNamePredicate)
in: .whitespacesAndNewlines)
print("🔢 After digit removal: \"\(text)\"") let matches = pasteField.licencesFound()
let licensesPredicates = matches.map { NSPredicate(format: "license contains[cd] %@", $0) }
// Split text by whitespace to get potential name components orPredicates = orPredicates + licensesPredicates
let textComponents = text.components(separatedBy: .whitespacesAndNewlines)
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter {
!$0.isEmpty && $0.count > 1 && !["de", "la", "le", "du", "et", "si"].contains($0.lowercased())
}
print("📚 Text components: \(textComponents)")
if textComponents.count < 50 {
print("✓ Text components count is reasonable: \(textComponents.count)")
// Handle exact fullname match
let fullName = textComponents.joined(separator: " ")
if !fullName.isEmpty {
print("📝 Adding predicate for full name: \"\(fullName)\"")
orPredicates.append(
NSPredicate(format: "canonicalFullName CONTAINS[cd] %@", fullName))
}
// Handle hyphenated last names
let hyphenatedComponents = textComponents.filter { $0.contains("-") }
print("🔗 Hyphenated components: \(hyphenatedComponents)")
for component in hyphenatedComponents {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", component))
print("➕ Added hyphenated last name predicate: \"\(component)\"")
// Also search for each part of the hyphenated name
let parts = component.components(separatedBy: "-")
for part in parts {
if part.count > 1 {
orPredicates.append(NSPredicate(format: "lastName CONTAINS[cd] %@", part))
print("➕ Added hyphenated part predicate: \"\(part)\"")
}
}
}
// Try different combinations for first/last name
if textComponents.count > 1 {
print("🔄 Creating combinations with \(textComponents.count) components")
// Try each pair of components as first+last and last+first
for i in 0..<textComponents.count-1 {
let j = i + 1
print("👥 Trying adjacent pair: \"\(textComponents[i])\" and \"\(textComponents[j])\"")
// orPredicates.append(
// NSPredicate(
// format:
// "(firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@) OR (firstName CONTAINS[cd] %@ AND lastName CONTAINS[cd] %@)",
// textComponents[i], textComponents[j], textComponents[j],
// textComponents[i]
// ))
// Also try beginswith for more precise matches
orPredicates.append(
NSPredicate(
format:
"(firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@) OR (firstName BEGINSWITH[cd] %@ AND lastName BEGINSWITH[cd] %@)",
textComponents[i], textComponents[j], textComponents[j],
textComponents[i]
))
}
} else if textComponents.count == 1 {
print("👤 Single component search: \"\(textComponents[0])\"")
// If only one component, search in both first and last name
orPredicates.append(
NSPredicate(
format: "firstName CONTAINS[cd] %@ OR lastName CONTAINS[cd] %@",
textComponents[0], textComponents[0]))
}
// Add pattern match for canonical full name
// let pattern = textComponents.joined(separator: ".*")
// print("🔍 Adding pattern match: \"\(pattern)\"")
// orPredicates.append(NSPredicate(format: "canonicalFullName MATCHES[c] %@", pattern))
} else {
print(" Too many text components: \(textComponents.count) - skipping name combinations")
}
// Construct final predicate
var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates) var predicate = NSCompoundPredicate(andPredicateWithSubpredicates: andPredicates)
print("📊 AND predicates count: \(andPredicates.count)")
if orPredicates.isEmpty == false {
if !orPredicates.isEmpty { predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [predicate, NSCompoundPredicate(orPredicateWithSubpredicates: orPredicates)])
print("📊 OR predicates count: \(orPredicates.count)")
let orCompoundPredicate = NSCompoundPredicate(
orPredicateWithSubpredicates: orPredicates)
predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
predicate, orCompoundPredicate,
])
} }
print("🏁 Final predicate created")
print(predicate)
return predicate return predicate
} }
@ -643,33 +379,28 @@ enum SearchToken: String, CaseIterable, Identifiable {
case rankLessThan = "rang <" case rankLessThan = "rang <"
case rankBetween = "rang <>" case rankBetween = "rang <>"
case age = "âge sportif" case age = "âge sportif"
var id: String { var id: String {
rawValue rawValue
} }
var message: String { var message: String {
switch self { switch self {
case .club: case .club:
return return "Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
"Taper le nom d'un club pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .ligue: case .ligue:
return return "Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
"Taper le nom d'une ligue pour y voir tous les joueurs ayant déjà joué un tournoi dans les 12 derniers mois."
case .rankMoreThan: case .rankMoreThan:
return return "Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
"Taper un nombre pour chercher les joueurs ayant un classement supérieur ou égale."
case .rankLessThan: case .rankLessThan:
return return "Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
"Taper un nombre pour chercher les joueurs ayant un classement inférieur ou égale."
case .rankBetween: case .rankBetween:
return return "Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
"Taper deux nombres séparés par une virgule pour chercher les joueurs dans cette intervalle de classement"
case .age: case .age:
return "Taper une année de naissance" return "Taper une année de naissance"
} }
} }
var titleLabel: String { var titleLabel: String {
switch self { switch self {
case .club: case .club:
@ -684,7 +415,7 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Chercher une année de naissance" return "Chercher une année de naissance"
} }
} }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self { switch self {
case .club: case .club:
@ -701,7 +432,7 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Année de naissance" return "Année de naissance"
} }
} }
var shortLocalizedLabel: String { var shortLocalizedLabel: String {
switch self { switch self {
case .club: case .club:
@ -718,7 +449,7 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "Né(e) en" return "Né(e) en"
} }
} }
func icon() -> String { func icon() -> String {
switch self { switch self {
case .club: case .club:
@ -735,7 +466,7 @@ enum SearchToken: String, CaseIterable, Identifiable {
return "figure.racquetball" return "figure.racquetball"
} }
} }
var systemImage: String { var systemImage: String {
switch self { switch self {
case .club: case .club:
@ -760,9 +491,9 @@ enum DataSet: Int, Identifiable {
case club case club
case favoriteClubs case favoriteClubs
case favoritePlayers case favoritePlayers
static let allCases: [DataSet] = [.national, .ligue, .club, .favoriteClubs] static let allCases : [DataSet] = [.national, .ligue, .club, .favoriteClubs]
var id: Int { rawValue } var id: Int { rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self { switch self {
@ -776,9 +507,9 @@ enum DataSet: Int, Identifiable {
return "Favori" return "Favori"
} }
} }
var tokens: [SearchToken] { var tokens: [SearchToken] {
var _tokens: [SearchToken] = [] var _tokens : [SearchToken] = []
switch self { switch self {
case .national: case .national:
_tokens = [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween] _tokens = [.club, .ligue, .rankMoreThan, .rankLessThan, .rankBetween]
@ -789,117 +520,80 @@ enum DataSet: Int, Identifiable {
case .favoritePlayers, .favoriteClubs: case .favoritePlayers, .favoriteClubs:
_tokens = [.rankMoreThan, .rankLessThan, .rankBetween] _tokens = [.rankMoreThan, .rankLessThan, .rankBetween]
} }
_tokens.append(.age) _tokens.append(.age)
return _tokens return _tokens
} }
} }
extension SortOption { enum SortOption: Int, CaseIterable, Identifiable {
case name
case rank
case tournamentCount
case points
case progression
var id: Int { self.rawValue }
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
switch self {
case .name:
return "Nom"
case .rank:
return "Rang"
case .tournamentCount:
return "Tournoi"
case .points:
return "Points"
case .progression:
return "Progression"
}
}
func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] { func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] {
switch self { switch self {
case .name: case .name:
return [ return [SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation)]
SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
]
case .rank: case .rank:
if dataSet == .national || dataSet == .ligue { if (dataSet == .national || dataSet == .ligue) {
return [ return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)]
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
]
} else { } else {
return [ return [SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
} }
case .tournamentCount: case .tournamentCount:
return [ return [SortDescriptor(\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
SortDescriptor(
\ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
case .points: case .points:
return [ return [SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
case .progression: case .progression:
return [ return [SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse), SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), SortDescriptor(\ImportedPlayer.lastName)]
SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
SortDescriptor(\ImportedPlayer.lastName),
]
} }
} }
} }
//enum SortOption: Int, CaseIterable, Identifiable { enum PlayerFilterOption: Int, Hashable, CaseIterable, Identifiable {
// case name case all = -1
// case rank case male = 1
// case tournamentCount case female = 0
// case points
// case progression var id: Int { rawValue }
//
// var id: Int { self.rawValue } func icon() -> String {
// func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { switch self {
// switch self { case .all:
// case .name: return "Tous"
// return "Nom" case .male:
// case .rank: return "Homme"
// return "Rang" case .female:
// case .tournamentCount: return "Femme"
// return "Tournoi" }
// case .points: }
// return "Points"
// case .progression: var localizedPlayerLabel: String {
// return "Progression" switch self {
// } case .female:
// } return "joueuse"
// default:
// func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor<ImportedPlayer>] { return "joueur"
// switch self { }
// case .name: }
// return [
// SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), }
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// ]
// case .rank:
// if dataSet == .national || dataSet == .ligue {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse)
// ]
// } else {
// return [
// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// case .tournamentCount:
// return [
// SortDescriptor(
// \ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .points:
// return [
// SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// case .progression:
// return [
// SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse),
// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation),
// SortDescriptor(\ImportedPlayer.lastName),
// ]
// }
// }
//}

@ -0,0 +1,66 @@
//
// SeedInterval.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/04/2024.
//
import Foundation
struct SeedInterval: Hashable, Comparable {
let first: Int
let last: Int
func pointsRange(tournamentLevel: TournamentLevel, teamsCount: Int) -> String {
tournamentLevel.pointsRange(first: first, last: last, teamsCount: teamsCount)
}
static func <(lhs: SeedInterval, rhs: SeedInterval) -> Bool {
return lhs.first < rhs.first
}
func isFixed() -> Bool {
first == 1 && last == 2
}
var count: Int {
dimension
}
private var dimension: Int {
(last - (first - 1))
}
func chunks() -> [SeedInterval]? {
if dimension > 3 {
let split = dimension / 2
if split%2 == 0 {
let firstHalf = SeedInterval(first: first, last: first + split - 1)
let secondHalf = SeedInterval(first: first + split, last: last)
return [firstHalf, secondHalf]
} else {
let firstHalf = SeedInterval(first: first, last: first + split)
let secondHalf = SeedInterval(first: first + split + 1, last: last)
return [firstHalf, secondHalf]
}
} else {
return nil
}
}
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String {
if dimension < 3 {
return "\(first)\(first.ordinalFormattedSuffix()) place"
} else {
return "Place \(first) à \(last)"
}
}
func localizedInterval(_ displayStyle: DisplayStyle = .wide) -> String {
if dimension < 3 {
return "#\(first) / #\(last)"
} else {
return "#\(first) à #\(last)"
}
}
}

@ -0,0 +1,89 @@
//
// Selectable.swift
// PadelClub
//
// Created by Razmig Sarkissian on 01/04/2024.
//
import Foundation
import SwiftUI
import TipKit
protocol Selectable {
func selectionLabel(index: Int) -> String
func badgeValue() -> Int?
func badgeImage() -> Badge?
func badgeValueColor() -> Color?
func displayImageIfValueZero() -> Bool
func systemImage() -> String?
func associatedTip() -> (any Tip)?
}
extension Selectable {
func associatedTip() -> (any Tip)? {
return nil
}
func systemImage() -> String? {
return nil
}
func displayImageIfValueZero() -> Bool {
return false
}
}
enum Badge {
case checkmark
case xmark
case custom(systemName: String, color: Color)
func systemName() -> String {
switch self {
case .checkmark:
return "checkmark.circle.fill"
case .xmark:
return "xmark.circle.fill"
case .custom(let systemName, _):
return systemName
}
}
func color() -> Color {
switch self {
case .checkmark:
.green
case .xmark:
.logoRed
case .custom(_, let color):
color
}
}
}
struct SelectionTipViewModifier: ViewModifier {
let selectable: Selectable
let action: () -> Void
func body(content: Content) -> some View {
if let tip = selectable.associatedTip() {
if #available(iOS 18.0, *) {
content
.popoverTip(tip, arrowEdge: .top) { _ in
action()
tip.invalidate(reason: .tipClosed)
}
} else {
content
}
} else {
content
}
}
}
extension View {
func selectableTipViewModifier(selectable: Selectable, action: @escaping () -> Void) -> some View {
modifier(SelectionTipViewModifier(selectable: selectable, action: action))
}
}

@ -0,0 +1,66 @@
//
// SetDescriptor.swift
// PadelClub
//
// Created by Razmig Sarkissian on 02/04/2024.
//
import Foundation
struct SetDescriptor: Identifiable, Equatable {
let id: UUID = UUID()
var valueTeamOne: Int?
var valueTeamTwo: Int?
var tieBreakValueTeamOne: Int?
var tieBreakValueTeamTwo: Int?
var setFormat: SetFormat
var showSetInputView: Bool = true
var showTieBreakInputView: Bool = false
var isTeamOneSet: Bool {
return valueTeamOne != nil || tieBreakValueTeamOne != nil
}
var hasEnded: Bool {
if let valueTeamTwo, let valueTeamOne {
return setFormat.hasEnded(teamOne: valueTeamOne, teamTwo: valueTeamTwo)
} else {
return false
}
}
var winner: TeamPosition? {
if let valueTeamTwo, let valueTeamOne {
return setFormat.winner(teamOne: valueTeamOne, teamTwo: valueTeamTwo)
} else {
return nil
}
}
var shouldTieBreak: Bool {
setFormat.shouldTiebreak(scoreTeamOne: valueTeamOne ?? 0, scoreTeamTwo: valueTeamTwo ?? 0)
}
func getValue(teamPosition: TeamPosition) -> String? {
switch teamPosition {
case .one:
if let valueTeamOne {
if let tieBreakValueTeamOne {
return "\(valueTeamOne)-\(tieBreakValueTeamOne)"
} else {
return "\(valueTeamOne)"
}
}
case .two:
if let valueTeamTwo {
if let tieBreakValueTeamTwo {
return "\(valueTeamTwo)-\(tieBreakValueTeamTwo)"
} else {
return "\(valueTeamTwo)"
}
}
}
return nil
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save