Compare commits

..

1 Commits
main ... clubs

Author SHA1 Message Date
Laurent 1b0694a708 First draft for new subscription 1 year ago
  1. 8
      CLAUDE.md
  2. 2091
      PadelClub.xcodeproj/project.pbxproj
  3. 78
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub ProdTest.xcscheme
  4. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  5. 78
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  6. 4
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  7. 3
      PadelClub.xcworkspace/contents.xcworkspacedata
  8. 61
      PadelClub/AppDelegate.swift
  9. 33
      PadelClub/Assets.xcassets/beigeNotUniversal.colorset/Contents.json
  10. 33
      PadelClub/Assets.xcassets/grayNotUniversal.colorset/Contents.json
  11. 6
      PadelClub/Assets.xcassets/logoRed.colorset/Contents.json
  12. 6
      PadelClub/Assets.xcassets/logoYellow.colorset/Contents.json
  13. 35
      PadelClub/Data/AppSettings.swift
  14. 248
      PadelClub/Data/Club.swift
  15. 40
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  16. 8
      PadelClub/Data/Coredata/PadelClubApp.xcdatamodeld/.xccurrentversion
  17. 30
      PadelClub/Data/Coredata/PadelClubApp.xcdatamodeld/Model_1_1.xcdatamodel/contents
  18. 47
      PadelClub/Data/Coredata/Persistence.swift
  19. 93
      PadelClub/Data/Court.swift
  20. 297
      PadelClub/Data/DataStore.swift
  21. 63
      PadelClub/Data/DateInterval.swift
  22. 151
      PadelClub/Data/Event.swift
  23. 77
      PadelClub/Data/Federal/FederalPlayer.swift
  24. 289
      PadelClub/Data/Federal/FederalTournament.swift
  25. 8
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  26. 16
      PadelClub/Data/Federal/PlayerHolder.swift
  27. 463
      PadelClub/Data/GroupStage.swift
  28. 959
      PadelClub/Data/Match.swift
  29. 740
      PadelClub/Data/MatchScheduler.swift
  30. 66
      PadelClub/Data/MockData.swift
  31. 101
      PadelClub/Data/MonthData.swift
  32. 574
      PadelClub/Data/PlayerRegistration.swift
  33. 21
      PadelClub/Data/README.md
  34. 755
      PadelClub/Data/Round.swift
  35. 649
      PadelClub/Data/TeamRegistration.swift
  36. 120
      PadelClub/Data/TeamScore.swift
  37. 2283
      PadelClub/Data/Tournament.swift
  38. 59
      PadelClub/Data/TournamentStore.swift
  39. 266
      PadelClub/Data/User.swift
  40. 51
      PadelClub/Extensions/Array+Extensions.swift
  41. 25
      PadelClub/Extensions/Badge+Extensions.swift
  42. 26
      PadelClub/Extensions/Calendar+Extensions.swift
  43. 22
      PadelClub/Extensions/CustomUser+Extensions.swift
  44. 234
      PadelClub/Extensions/Date+Extensions.swift
  45. 25
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  46. 23
      PadelClub/Extensions/Locale+Extensions.swift
  47. 49
      PadelClub/Extensions/MonthData+Extensions.swift
  48. 27
      PadelClub/Extensions/MySortDescriptor.swift
  49. 16
      PadelClub/Extensions/NumberFormatter+Extensions.swift
  50. 230
      PadelClub/Extensions/PlayerRegistration+Extensions.swift
  51. 33
      PadelClub/Extensions/Round+Extensions.swift
  52. 87
      PadelClub/Extensions/Sequence+Extensions.swift
  53. 34
      PadelClub/Extensions/SourceFileManager+Extensions.swift
  54. 42
      PadelClub/Extensions/SpinDrawable+Extensions.swift
  55. 47
      PadelClub/Extensions/String+Crypto.swift
  56. 203
      PadelClub/Extensions/String+Extensions.swift
  57. 84
      PadelClub/Extensions/TeamRegistration+Extensions.swift
  58. 428
      PadelClub/Extensions/Tournament+Extensions.swift
  59. 164
      PadelClub/Extensions/URL+Extensions.swift
  60. 22
      PadelClub/Extensions/View+Extensions.swift
  61. 5
      PadelClub/HTML Templates/bracket-template.html
  62. 1
      PadelClub/HTML Templates/groupstage-template.html
  63. 14
      PadelClub/HTML Templates/match-template.html
  64. 1
      PadelClub/HTML Templates/player-template.html
  65. 36
      PadelClub/HTML Templates/tournament-template.html
  66. 2
      PadelClub/Info.plist
  67. 60
      PadelClub/OnlineRegistrationWarningView.swift
  68. 190
      PadelClub/PadelClubApp.swift
  69. BIN
      PadelClub/SeedData/local.sqlite
  70. 69
      PadelClub/SyncedProducts.storekit
  71. 224
      PadelClub/Utils/CloudConvert.swift
  72. 205
      PadelClub/Utils/ContactManager.swift
  73. 29
      PadelClub/Utils/DisplayContext.swift
  74. 37
      PadelClub/Utils/ExportFormat.swift
  75. 147
      PadelClub/Utils/FileImportManager.swift
  76. 25
      PadelClub/Utils/HtmlGenerator.swift
  77. 160
      PadelClub/Utils/HtmlService.swift
  78. 12
      PadelClub/Utils/Key.swift
  79. 17
      PadelClub/Utils/LocationManager.swift
  80. 90
      PadelClub/Utils/Network/ConfigurationService.swift
  81. 302
      PadelClub/Utils/Network/FederalDataService.swift
  82. 142
      PadelClub/Utils/Network/NetworkFederalService.swift
  83. 5
      PadelClub/Utils/Network/NetworkManager.swift
  84. 28
      PadelClub/Utils/Network/NetworkManagerError.swift
  85. 77
      PadelClub/Utils/Network/PaymentService.swift
  86. 50
      PadelClub/Utils/Network/RefundService.swift
  87. 207
      PadelClub/Utils/Network/StripeValidationService.swift
  88. 83
      PadelClub/Utils/Network/XlsToCsvService.swift
  89. 47
      PadelClub/Utils/PListReader.swift
  90. 1612
      PadelClub/Utils/PadelRule.swift
  91. 119
      PadelClub/Utils/Patcher.swift
  92. 59
      PadelClub/Utils/PhoneNumbersUtils.swift
  93. 257
      PadelClub/Utils/SourceFileManager.swift
  94. 72
      PadelClub/Utils/SwiftParser.swift
  95. 200
      PadelClub/Utils/Tips.swift
  96. 76
      PadelClub/Utils/URLs.swift
  97. 33
      PadelClub/Utils/VersionComparator.swift
  98. 59
      PadelClub/ViewModel/AgendaDestination.swift
  99. 146
      PadelClub/ViewModel/FederalDataViewModel.swift
  100. 101
      PadelClub/ViewModel/MatchDescriptor.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,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF4CBF3E2C996C0600151637"
BuildableName = "ProdTest PadelClub.app"
BlueprintName = "ProdTest PadelClub"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

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

@ -1,78 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Release"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "FF70FABD2C90584900129CC2"
BuildableName = "PadelClub TestFlight.app"
BlueprintName = "PadelClub TestFlight"
ReferencedContainer = "container:PadelClub.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1630"
LastUpgradeVersion = "1530"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
@ -74,7 +74,7 @@
</BuildableReference>
</BuildableProductRunnable>
<StoreKitConfigurationFileReference
identifier = "../PadelClub/SyncedProducts.storekit">
identifier = "../../PadelClub/SyncedProducts.storekit">
</StoreKitConfigurationFileReference>
</LaunchAction>
<ProfileAction

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

@ -9,76 +9,21 @@ import Foundation
import UIKit
import LeStorage
import UserNotifications
import PadelClubData
class AppDelegate : NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
_ = Guard.main // init guard
self._configureLeStorage()
UIApplication.shared.registerForRemoteNotifications()
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()
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()
}
Logger.log("didFinishLaunchingWithOptions")
return true
}
// MARK: - Remote Notifications
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
if StoreCenter.main.isAuthenticated {
if StoreCenter.main.hasToken() {
Task {
do {
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
}
}

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x38",
"green" : "0x40",
"red" : "0xE8"
"blue" : "0.220",
"green" : "0.251",
"red" : "0.910"
}
},
"idiom" : "universal"

@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0x00",
"green" : "0xD2",
"red" : "0xFF"
"blue" : "0.000",
"green" : "0.827",
"red" : "1.000"
}
},
"idiom" : "universal"

@ -0,0 +1,35 @@
//
// 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
required init() {
}
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
}
enum CodingKeys: String, CodingKey {
case _lastDataSource = "lastDataSource"
case _didCreateAccount = "didCreateAccount"
case _didRegisterAccount = "didRegisterAccount"
}
}

@ -0,0 +1,248 @@
//
// 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 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) {
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
}
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 _alphabeticalName = "alphabeticalName"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
if let creator = creator {
try container.encode(creator, forKey: ._creator)
} else {
try container.encodeNil(forKey: ._creator)
}
try container.encode(name, forKey: ._name)
try container.encode(acronym, forKey: ._acronym)
if let phone = phone {
try container.encode(phone, forKey: ._phone)
} else {
try container.encodeNil(forKey: ._phone)
}
if let code = code {
try container.encode(code, forKey: ._code)
} else {
try container.encodeNil(forKey: ._code)
}
if let address = address {
try container.encode(address, forKey: ._address)
} else {
try container.encodeNil(forKey: ._address)
}
if let city = city {
try container.encode(city, forKey: ._city)
} else {
try container.encodeNil(forKey: ._city)
}
if let zipCode = zipCode {
try container.encode(zipCode, forKey: ._zipCode)
} else {
try container.encodeNil(forKey: ._zipCode)
}
if let latitude = latitude {
try container.encode(latitude, forKey: ._latitude)
} else {
try container.encodeNil(forKey: ._latitude)
}
if let longitude = longitude {
try container.encode(longitude, forKey: ._longitude)
} else {
try container.encodeNil(forKey: ._longitude)
}
try container.encode(courtCount, forKey: ._courtCount)
if let broadcastCode {
try container.encode(broadcastCode, forKey: ._broadcastCode)
} else {
try container.encodeNil(forKey: ._broadcastCode)
}
// try container.encode(alphabeticalName, forKey: ._alphabeticalName)
}
}
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 PadelClubData
extension ImportedPlayer: PlayerHolder {
func getAssimilatedAsMaleRank() -> Int? {
@ -14,13 +13,7 @@ extension ImportedPlayer: PlayerHolder {
return getRank()?.femaleInMaleAssimilation
}
var computedAge: Int? {
let year = Calendar.current.getSportAge()
if let yearOfBirth = birthYear?.toInt() {
return year - yearOfBirth
}
return nil
}
var computedAge: Int? { nil }
var tournamentPlayed: Int? {
Int(tournamentCount)
@ -58,11 +51,7 @@ extension ImportedPlayer: PlayerHolder {
func isMalePlayer() -> Bool {
male
}
func pasteData(withRank: Bool = false) -> String {
return [firstName?.capitalized, lastName?.capitalized, license?.computedLicense, withRank ? "(\(rank.ordinalFormatted(feminine: isMalePlayer() == false)))" : nil].compactMap({ $0 }).joined(separator: " ")
}
func isNotFromCurrentDate() -> Bool {
if let importDate, importDate != SourceFileManager.shared.lastDataSourceDate() {
return true
@ -71,12 +60,7 @@ extension ImportedPlayer: PlayerHolder {
}
}
func contains(_ searchField: String) -> Bool {
firstName?.localizedCaseInsensitiveContains(searchField) == true || lastName?.localizedCaseInsensitiveContains(searchField) == true
}
func hitForSearch(_ searchText: String?) -> Int {
guard let searchText else { return 0 }
func hitForSearch(_ searchText: String) -> Int {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .symbols, replacementString: " ")
@ -112,26 +96,10 @@ extension ImportedPlayer: PlayerHolder {
}
return 0
}
func getBirthYear() -> Int? {
if let birthYear {
return Int(birthYear)
} else {
return nil
}
}
func getProgression() -> Int {
return Int(progression)
}
func getComputedRank() -> Int? {
nil
}
}
fileprivate extension Int {
var femaleInMaleAssimilation: Int {
self + TournamentCategory.femaleInMaleAssimilationAddition(self, seasonYear: Date.now.seasonYear())
self + TournamentCategory.femaleInMaleAssimilationAddition(self)
}
}

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>Model_1_1.xcdatamodel</string>
</dict>
</plist>

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E214" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="v1.1">
<entity name="ImportedPlayer" representedClassName=".ImportedPlayer" syncable="YES" codeGenerationType="class">
<attribute name="assimilation" attributeType="String"/>
<attribute name="bestRank" optional="YES" attributeType="String"/>
<attribute name="birthYear" optional="YES" attributeType="String"/>
<attribute name="canonicalFirstName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(firstName)"/>
<attribute name="canonicalFullName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(fullName)"/>
<attribute name="canonicalLastName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(lastName)"/>
<attribute name="clubCode" attributeType="String"/>
<attribute name="clubName" attributeType="String"/>
<attribute name="country" attributeType="String"/>
<attribute name="firstName" attributeType="String"/>
<attribute name="fullName" attributeType="String"/>
<attribute name="importDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="lastName" attributeType="String"/>
<attribute name="license" attributeType="String"/>
<attribute name="ligueName" attributeType="String"/>
<attribute name="male" attributeType="Boolean" usesScalarValueType="YES"/>
<attribute name="points" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="progression" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="rank" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="tournamentCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
<uniquenessConstraints>
<uniquenessConstraint>
<constraint value="license"/>
</uniquenessConstraint>
</uniquenessConstraints>
</entity>
</model>

@ -10,27 +10,7 @@ import CoreData
class PersistenceController: NSObject {
static let shared = PersistenceController()
private static var prepopulatedSeed: URL? {
let url = Bundle.main.url(forResource: "local", withExtension: "sqlite")
print("prepopulatedSeed", url)
return url
}
private static var _model: NSManagedObjectModel?
static func getModelVersion() -> String? {
if let versions = _model?.versionIdentifiers {
let currentVersion = versions.compactMap { $0 as? String }.joined(separator: ",")
//print("Current Model Version: \(currentVersion)")
// Compare the current model version with the saved version
return currentVersion
}
return nil
}
private static func model(name: String) throws -> NSManagedObjectModel {
if _model == nil {
_model = try loadModel(name: name, bundle: Bundle.main)
@ -59,19 +39,6 @@ class PersistenceController: NSObject {
let localStoreFolderURL = storeFolderURL.appendingPathComponent("Local")
let fileManager = FileManager.default
var firstLaunch = false
if fileManager.fileExists(atPath: localStoreFolderURL.appendingPathComponent("local.sqlite").path) == false {
firstLaunch = true
}
do {
let contents = try fileManager.contentsOfDirectory(atPath: localStoreFolderURL.path)
print("Directory contents: \(contents)")
} catch {
print("Failed to list directory contents: \(error)")
}
for folderURL in [localStoreFolderURL] where !fileManager.fileExists(atPath: folderURL.path) {
do {
try fileManager.createDirectory(at: folderURL, withIntermediateDirectories: true, attributes: nil)
@ -80,17 +47,6 @@ class PersistenceController: NSObject {
}
}
if firstLaunch, let seedURL = PersistenceController.prepopulatedSeed {
let folderPath = seedURL.path()
let localStoreFileURL = localStoreFolderURL.appendingPathComponent("local.sqlite")
do {
try fileManager.copyItem(at: seedURL, to: localStoreFileURL)
} catch {
print("Error info: \(error)")
}
}
let container = NSPersistentContainer(name: "PadelClubApp", managedObjectModel: try! Self.model(name: "PadelClubApp"))
guard let localStoreDescription = container.persistentStoreDescriptions.first!.copy() as? NSPersistentStoreDescription else {
@ -219,9 +175,6 @@ class PersistenceController: NSObject {
importedPlayer.clubCode = data.clubCode.replaceCharactersFromSet(characterSet: .whitespaces)
importedPlayer.male = data.isMale
importedPlayer.importDate = importingDate
importedPlayer.progression = Int64(data.progression)
importedPlayer.bestRank = data.bestRank?.formattedAsRawString()
importedPlayer.birthYear = data.birthYear?.formattedAsRawString()
}
// 5

@ -0,0 +1,93 @@
//
// 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)
if let name = name {
try container.encode(name, forKey: ._name)
} else {
try container.encodeNil(forKey: ._name)
}
try container.encode(exitAllowed, forKey: ._exitAllowed)
try container.encode(indoor, forKey: ._indoor)
}
}

@ -0,0 +1,297 @@
//
// 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 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
_ = Guard.main // init
#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)
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)
}
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
self.tournaments.reset()
self.clubs.reset()
self.courts.reset()
self.events.reset()
self.dateIntervals.reset()
self.userStorage.reset()
for tournament in self.tournaments {
StoreCenter.main.destroyStore(identifier: tournament.id)
}
Guard.main.disconnect()
self.user = self._temporaryLocalUser.item ?? User.placeHolder()
self.user.clubs.removeAll()
StoreCenter.main.disconnect()
}
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 }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
var runningMatches: [Match] = []
for tournament in lastTournaments {
let matches = tournament.tournamentStore.matches.filter { match in
match.confirmed && match.startDate != nil && match.endDate == nil }
runningMatches.append(contentsOf: matches)
}
return runningMatches
}
}

@ -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,151 @@
//
// 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)
if let creator = creator {
try container.encode(creator, forKey: ._creator)
} else {
try container.encodeNil(forKey: ._creator)
}
if let club = club {
try container.encode(club, forKey: ._club)
} else {
try container.encodeNil(forKey: ._club)
}
try container.encode(creationDate, forKey: ._creationDate)
if let name = name {
try container.encode(name, forKey: ._name)
} else {
try container.encodeNil(forKey: ._name)
}
if let tenupId = tenupId {
try container.encode(tenupId, forKey: ._tenupId)
} else {
try container.encodeNil(forKey: ._tenupId)
}
}
}

@ -20,9 +20,6 @@ class FederalPlayer: Decodable {
var clubCode: String
var club: String
var isMale: Bool
var birthYear: Int?
var progression: Int
var bestRank: Int?
// MARK: - Nationnalite
@ -31,75 +28,54 @@ class FederalPlayer: Decodable {
}
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 {
case nom
case prenom
case licence
case meilleurClassement
case nationalite
case nationnalite
case anneeNaissance
case codeClub
case nomClub
case ligue
case classement
case evolution
case nomLigue
case rang
case progression
case points
case nombreTournoisJoues
case assimilation
case ageSportif
case nombreDeTournois
case assimile
}
let container = try decoder.container(keyedBy: CodingKeys.self)
isMale = (decoder.userInfo[.maleData] as? Bool) == true
let _lastName = try container.decodeIfPresent(String.self, forKey: .nom)
let _firstName = try container.decodeIfPresent(String.self, forKey: .prenom)
lastName = _lastName ?? ""
firstName = _firstName ?? ""
let _lastName = try container.decode(String.self, forKey: .nom)
let _firstName = try container.decode(String.self, forKey: .prenom)
lastName = _lastName
firstName = _firstName
if let lic = try? container.decodeIfPresent(Int.self, forKey: .licence) {
license = String(lic)
} else {
license = ""
}
country = try container.decodeIfPresent(String.self, forKey: .nationalite) ?? ""
bestRank = try container.decodeIfPresent(Int.self, forKey: .meilleurClassement)
let ageSportif = try container.decodeIfPresent(Int.self, forKey: .ageSportif)
if let ageSportif {
let month = Calendar.current.component(.month, from: Date())
if month > 8 {
birthYear = Calendar.current.component(.year, from: Date()) + 1 - ageSportif
} 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 nationnalite = try container.decode(Nationnalite.self, forKey: .nationnalite)
country = nationnalite.code
//meilleurClassement = try container.decode(Int.self, forKey: .meilleurClassement)
//anneeNaissance = try container.decode(Int.self, forKey: .anneeNaissance)
clubCode = try container.decode(String.self, forKey: .codeClub)
club = try container.decode(String.self, forKey: .nomClub)
ligue = try container.decode(String.self, forKey: .nomLigue)
rank = try container.decode(Int.self, forKey: .rang)
//progression = try? container.decodeIfPresent(Int.self, forKey: .progression)
let pointsAsInt = try? container.decodeIfPresent(Int.self, forKey: .points)
if let pointsAsInt {
points = Double(pointsAsInt)
} else {
points = nil
}
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreTournoisJoues)
let assimile = try container.decode(Bool.self, forKey: .assimilation)
tournamentCount = try? container.decodeIfPresent(Int.self, forKey: .nombreDeTournois)
let assimile = try container.decode(Bool.self, forKey: .assimile)
assimilation = assimile ? "Oui" : "Non"
}
@ -108,12 +84,11 @@ class FederalPlayer: Decodable {
let pointsString = points != nil ? String(Int(points!)) : ""
let tournamentCountString = tournamentCount != nil ? String(tournamentCount!) : ""
let strippedLicense = license.strippedLicense ?? ""
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);\(progression.formattedAsRawString());\(bestRank?.formattedAsRawString() ?? "");\(birthYear?.formattedAsRawString() ?? "");"
let line = ";\(rank);\(lastName);\(firstName);\(country);\(strippedLicense);\(pointsString);\(assimilation);\(tournamentCountString);\(ligue);\(formatNumbers(clubCode));\(club);"
return line
}
func formatNumbers(_ input: String) -> String {
if input.isEmpty { return input }
// Insert spaces at appropriate positions
let formattedString = insertSeparator(input, separator: " ", every: [2, 4])
return formattedString
@ -192,9 +167,6 @@ class FederalPlayer: Decodable {
ligue = result[8]
clubCode = result[9]
club = result[10]
progression = result[safe: 11]?.toInt() ?? 0
bestRank = result[safe: 12]?.toInt()
birthYear = result[safe: 13]?.toInt()
}
static func anonymousCount(mostRecentDateAvailable: Date?) async -> Int? {
@ -227,9 +199,8 @@ class FederalPlayer: Decodable {
rankPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [rankPredicate, NSPredicate(format: "importDate == %@", mostRecentDateAvailable as CVarArg)])
}
fetch.predicate = rankPredicate
print(fetch.predicate)
let lastPlayersCount = try context.count(for: fetch)
print(Int(lr), Int(lastPlayersCount) - 1, count)
return (Int(lr) + Int(lastPlayersCount) - 1, count)
}
} catch {

@ -6,11 +6,15 @@
import Foundation
import CoreLocation
import LeStorage
import PadelClubData
enum DayPeriod {
case all
case weekend
case week
}
// MARK: - FederalTournament
struct FederalTournament: Identifiable, Codable, Hashable {
struct FederalTournament: Identifiable, Codable {
func getEvent() -> Event {
let club = DataStore.shared.user.clubsObjects().first(where: { $0.code == codeClub })
@ -23,14 +27,6 @@ struct FederalTournament: Identifiable, Codable, Hashable {
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!
}
@ -41,7 +37,7 @@ struct FederalTournament: Identifiable, Codable, Hashable {
}
let id: String
let id: Int
var millesime: Int?
var libelle: String?
var tmc: Bool?
@ -81,106 +77,8 @@ struct FederalTournament: Identifiable, Codable, Hashable {
var dateFin, dateValidation: Date?
var codePostalEngagement, codeClub: String?
var prixEspece: Int?
var japPhoneNumber: String?
mutating func updateJapPhoneNumber(phone: String?) {
self.japPhoneNumber = phone
}
var distanceEnMetres: Double?
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 {
if let dateDebut {
let day = dateDebut.get(.weekday)
@ -228,47 +126,10 @@ struct FederalTournament: Identifiable, Codable, Hashable {
?? []
}
var federalClub: FederalClub? {
if let codeClub {
return FederalClub(federalClubCode: codeClub, federalClubName: clubLabel())
} else {
return nil
}
}
var shareMessage: String {
[libelle, dateDebut?.formatted(date: .complete, time: .omitted)].compactMap({$0}).joined(separator: "\n") + "\n"
}
var sharePartnerMessage: String {
["Je nous ai inscris au tournoi suivant : ",
libelle,
dateDebut?.formatted(date: .complete, time: .omitted),
"message preparé par Padel Club",
URLs.appStore.rawValue
].compactMap({$0}).joined(separator: "\n") + "\n"
}
func calendarNoteMessage() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, installation?.telephone].compactMap({$0}).joined(separator: "\n")
}
var japMessage: String {
[nomClub, jugeArbitre?.nom, jugeArbitre?.prenom, courrielEngagement, japPhoneNumber].compactMap({$0}).joined(separator: ";")
}
func umpireLabel() -> String {
[jugeArbitre?.nom, jugeArbitre?.prenom].compactMap({$0}).map({ $0.lowercased().capitalized }).joined(separator: " ")
}
func phoneLabel() -> String {
[installation?.telephone].compactMap({$0}).joined(separator: " ")
}
func mailLabel() -> String {
[courrielEngagement].compactMap({$0}).joined(separator: " ")
}
func validForSearch(_ searchText: String, scope: FederalTournamentSearchScope) -> Bool {
var trimmedSearchText = searchText.lowercased().trimmingCharacters(in: .whitespaces).folding(options: .diacriticInsensitive, locale: .current)
trimmedSearchText = trimmedSearchText.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ")
@ -295,25 +156,18 @@ extension FederalTournament: FederalTournamentHolder {
// var importedId: Int { id }
var holderId: String { id.string }
func clubLabel() -> String {
nomClub ?? villeEngagement ?? installation?.nom ?? ""
}
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
func subtitleLabel() -> String {
""
}
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String {
build.level.localizedLevelLabel(displayStyle)
}
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool {
true
}
}
// MARK: - CategorieAge
struct CategorieAge: Codable, Hashable {
struct CategorieAge: Codable {
var ageJoueurMin, ageMin, ageJoueurMax, ageRechercheMax: Int?
var categoriesAgeTypePratique: [CategoriesAgeTypePratique]?
var ageMax: Int?
@ -325,28 +179,35 @@ struct CategorieAge: Codable, Hashable {
var tournamentAge: FederalTournamentAge? {
if let id {
return FederalTournamentAge(rawValue: id) ?? .senior
return FederalTournamentAge(rawValue: id)
}
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
struct CategoriesAgeTypePratique: Codable, Hashable {
struct CategoriesAgeTypePratique: Codable {
var id: ID?
}
// MARK: - ID
struct ID: Codable, Hashable {
var typePratique: String?
struct ID: Codable {
var typePratique: TypePratique?
var idCategorieAge: Int?
}
enum TypePratique: String, Codable {
case beach = "BEACH"
case padel = "PADEL"
case tennis = "TENNIS"
case pickle = "PICKLE"
}
// MARK: - CategorieTournoi
struct CategorieTournoi: Codable, Hashable {
struct CategorieTournoi: Codable {
var code, codeTaxe: String?
var compteurGda: CompteurGda?
var libelle, niveauHierarchique: String?
@ -354,14 +215,14 @@ struct CategorieTournoi: Codable, Hashable {
}
// MARK: - CompteurGda
struct CompteurGda: Codable, Hashable {
struct CompteurGda: Codable {
var classementMax: Classement?
var libelle: String?
var classementMin: Classement?
}
// MARK: - Classement
struct Classement: Codable, Hashable {
struct Classement: Codable {
var nature, libelle: String?
var serie: Serie?
var sexe: String?
@ -371,18 +232,18 @@ struct Classement: Codable, Hashable {
}
// MARK: - Serie
struct Serie: Codable, Hashable {
struct Serie: Codable {
var code, libelle: String?
var valide: Bool?
var sexe: String?
var tournamentCategory: TournamentCategory? {
TournamentCategory.allCases.first(where: { $0.requestLabel == code }) ?? .men
TournamentCategory.allCases.first(where: { $0.requestLabel == code })
}
}
// MARK: - Epreuve
struct Epreuve: Codable, Hashable {
struct Epreuve: Codable {
var inscriptionEnLigneEnCours: Bool?
var categorieAge: CategorieAge?
var typeEpreuve: TypeEpreuve?
@ -419,7 +280,7 @@ struct Epreuve: Codable, Hashable {
}
// MARK: - TypeEpreuve
struct TypeEpreuve: Codable, Hashable {
struct TypeEpreuve: Codable {
let code: String?
let delai: Int?
let libelle: String?
@ -430,19 +291,19 @@ struct TypeEpreuve: Codable, Hashable {
var tournamentLevel: TournamentLevel? {
if let code, let value = Int(code.removingFirstCharacter) {
return TournamentLevel(rawValue: value) ?? .p100
return TournamentLevel(rawValue: value)
}
return .p100
return nil
}
}
// MARK: - BorneAnneesNaissance
struct BorneAnneesNaissance: Codable, Hashable {
struct BorneAnneesNaissance: Codable {
var min, max: Int?
}
// MARK: - Installation
struct Installation: Codable, Hashable {
struct Installation: Codable {
var ville: String?
var lng: Double?
var surfaces: [JSONAny]?
@ -457,7 +318,7 @@ struct Installation: Codable, Hashable {
}
// MARK: - JugeArbitre
struct JugeArbitre: Codable, Hashable {
struct JugeArbitre: Codable {
var idCRM, id: Int?
var nom, prenom: String?
@ -468,7 +329,7 @@ struct JugeArbitre: Codable, Hashable {
}
// MARK: - ModeleDeBalle
struct ModeleDeBalle: Codable, Hashable {
struct ModeleDeBalle: Codable {
var libelle: String?
var marqueDeBalle: MarqueDeBalle?
var id: Int?
@ -476,7 +337,7 @@ struct ModeleDeBalle: Codable, Hashable {
}
// MARK: - MarqueDeBalle
struct MarqueDeBalle: Codable, Hashable {
struct MarqueDeBalle: Codable {
var id: Int?
var valide: Bool?
var marque: String?
@ -529,13 +390,9 @@ class JSONCodingKey: CodingKey {
}
}
class JSONAny: Codable, Hashable, Equatable {
var value: Any
class JSONAny: Codable {
init() {
self.value = ()
}
let value: Any
static func decodingError(forCodingPath codingPath: [CodingKey]) -> DecodingError {
let context = DecodingError.Context(codingPath: codingPath, debugDescription: "Cannot decode JSONAny")
@ -726,70 +583,4 @@ class JSONAny: Codable, Hashable, Equatable {
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,23 +6,19 @@
//
import Foundation
import PadelClubData
protocol FederalTournamentHolder {
var holderId: String { get }
var startDate: Date { get }
var endDate: Date? { get }
var codeClub: String? { get }
var tournaments: [any TournamentBuildHolder] { get }
func clubLabel() -> String
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String
func subtitleLabel() -> String
var dayDuration: Int { get }
var dayPeriod: DayPeriod { get }
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool
}
extension FederalTournamentHolder {
func durationLabel() -> String {
switch dayDuration {
case 1:

@ -6,7 +6,6 @@
//
import Foundation
import SwiftUI
protocol PlayerHolder {
@ -25,9 +24,6 @@ protocol PlayerHolder {
var computedAge: Int? { get }
func getAssimilatedAsMaleRank() -> Int?
func isNotFromCurrentDate() -> Bool
func getBirthYear() -> Int?
func getProgression() -> Int
func getComputedRank() -> Int?
}
extension PlayerHolder {
@ -42,16 +38,4 @@ extension PlayerHolder {
func isAnonymous() -> Bool {
getFirstName().isEmpty && getLastName().isEmpty
}
func getProgressionColor(progression: Int) -> Color {
switch progression {
case _ where progression > 0:
return Color.green
case _ where progression < 0:
return Color.logoRed
default:
return Color.primary
}
}
}

@ -0,0 +1,463 @@
//
// 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 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) {
self.tournament = tournament
self.index = index
self.size = size
self.format = matchFormat
self.startDate = startDate
self.name = name
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
// MARK: - Computed dependencies
func _matches() -> [Match] {
return self.tournamentStore.matches.filter { $0.groupStage == self.id }
// Store.main.filter { $0.groupStage == self.id }
}
func tournamentObject() -> Tournament? {
Store.main.findById(self.tournament)
}
// MARK: -
func teamAt(groupStagePosition: Int) -> TeamRegistration? {
teams().first(where: { $0.groupStagePosition == groupStagePosition })
}
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String {
if let name { return name }
switch displayStyle {
case .wide, .title:
return "Poule \(index + 1)"
case .short:
return "#\(index + 1)"
}
}
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 {
guard teams().count == size else { return false }
let _matches = _matches()
if _matches.isEmpty { 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
return match
}
func buildMatches() {
_removeMatches()
var matches = [Match]()
var teamScores = [TeamScore]()
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)
}
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()
if ordered.isEmpty == false && ordered.count == _matchOrder().count {
return _matchOrder().map {
ordered[$0]
}
} else {
return ordered
}
}
func updateGroupStageState() {
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()?.resetTeamScores(in: team.bracketPosition)
team.bracketPosition = nil
}
}
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} 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: false)))
let gameDifference = scoreData.gameDifference.formatted(.number.sign(strategy: .always(includingZero: false)))
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) }
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) }
}
func initialStartDate(forTeam team: TeamRegistration) -> Date? {
guard let groupStagePosition = team.groupStagePosition 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]) -> [Match] {
#if _DEBUG_TIME //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.canBeStarted(inMatches: runningMatches) && $0.isRunning() == false })
}
func runningMatches(playedMatches: [Match]) -> [Match] {
#if _DEBUG_TIME //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_TIME //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_TIME //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()
}
private func _matchOrder() -> [Int] {
switch size {
case 3:
return [1, 2, 0]
case 4:
return [2, 3, 1, 4, 5, 0]
case 5:
return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9]
// return [3, 5, 8, 2, 6, 7, 1, 9, 4, 0]
case 6:
return [1, 7, 13, 11, 3, 6, 10, 2, 8, 12, 5, 4, 9, 14, 0]
//return [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0]
default:
return []
}
}
private func _matchUp(for matchIndex: Int) -> [Int] {
Array((0..<size).combinations(ofCount: 2))[safe: matchIndex] ?? []
}
func localizedMatchUpLabel(for matchIndex: Int) -> String {
let matchUp = _matchUp(for: matchIndex)
if let index = matchUp.first, let index2 = matchUp.last {
return "#\(index + 1) contre #\(index2 + 1)"
} else {
return "--"
}
}
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]?.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))
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] {
return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil }
}
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.groupStagePosition!)
}).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) },
{ $0.team.groupStagePosition! > $1.team.groupStagePosition! }
]
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 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")
}
override func deleteDependencies() throws {
let matches = self._matches()
for match in matches {
try match.deleteDependencies()
}
self.tournamentStore.matches.deleteDependencies(matches)
}
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)
if let format = format {
try container.encode(format, forKey: ._format)
} else {
try container.encodeNil(forKey: ._format)
}
if let startDate = startDate {
try container.encode(startDate, forKey: ._startDate)
} else {
try container.encodeNil(forKey: ._startDate)
}
if let name = name {
try container.encode(name, forKey: ._name)
} else {
try container.encodeNil(forKey: ._name)
}
}
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"
}
}
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
}
}
}

@ -0,0 +1,959 @@
//
// Match.swift
// Padel Tournament
//
// Created by razmig on 10/03/2024.
//
import Foundation
import LeStorage
@Observable
final class Match: ModelObject, Storable {
static func resourceName() -> String { "matches" }
static func tokenExemptedMethods() -> [HTTPMethod] { return [] }
static func filterByStoreIdentifier() -> Bool { return true }
static var relationshipNames: [String] = ["round", "groupStage"]
static func setServerTitle(upperRound: Round, matchIndex: Int) -> String {
if upperRound.index == 0 { return upperRound.roundTitle() }
return upperRound.roundTitle() + " #" + (matchIndex + 1).formatted()
}
var byeState: Bool = false
var id: String = Store.randomId()
var round: String?
var groupStage: String?
var startDate: Date?
var endDate: Date?
var index: Int
private var format: MatchFormat?
//var court: String?
var servingTeamId: String?
var winningTeamId: String?
var losingTeamId: String?
//var broadcasted: Bool
var name: String?
//var order: Int
var disabled: Bool = false
private(set) var courtIndex: Int?
var confirmed: Bool = false
init(round: String? = nil, groupStage: String? = nil, startDate: Date? = nil, endDate: Date? = nil, index: Int, matchFormat: MatchFormat? = nil, servingTeamId: String? = nil, winningTeamId: String? = nil, losingTeamId: String? = nil, name: String? = nil, disabled: Bool = false, courtIndex: Int? = nil, confirmed: Bool = false) {
self.round = round
self.groupStage = groupStage
self.startDate = startDate
self.endDate = endDate
self.index = index
self.format = matchFormat
//self.court = court
self.servingTeamId = servingTeamId
self.winningTeamId = winningTeamId
self.losingTeamId = losingTeamId
self.disabled = disabled
self.name = name
self.courtIndex = courtIndex
self.confirmed = confirmed
// self.broadcasted = broadcasted
// self.order = order
}
var tournamentStore: TournamentStore {
if let store = self.store as? TournamentStore {
return store
}
fatalError("missing store for \(String(describing: type(of: self)))")
}
var courtIndexForSorting: Int {
courtIndex ?? Int.max
}
// MARK: - Computed dependencies
var teamScores: [TeamScore] {
return self.tournamentStore.teamScores.filter { $0.match == self.id }
}
// MARK: -
override func deleteDependencies() throws {
guard let tournament = self.currentTournament() else {
return
}
let teamScores = self.teamScores
for teamScore in teamScores {
try teamScore.deleteDependencies()
}
tournament.tournamentStore.teamScores.deleteDependencies(teamScores)
}
func indexInRound(in matches: [Match]? = nil) -> Int {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func indexInRound(in", matches?.count, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if groupStage != nil {
return index
} else if let index = (matches ?? roundObject?.playedMatches().sorted(by: \.index))?.firstIndex(where: { $0.id == id }) {
return index
}
return RoundRule.matchIndexWithinRound(fromMatchIndex: index)
}
func matchWarningSubject() -> String {
[roundTitle(), matchTitle(.short)].compacted().joined(separator: " ")
}
func matchWarningMessage() -> String {
[roundTitle(), matchTitle(.short), startDate?.localizedDate(), courtName()].compacted().joined(separator: "\n")
}
func matchTitle(_ displayStyle: DisplayStyle = .wide, inMatches matches: [Match]? = nil) -> String {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func matchTitle", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if let groupStageObject {
return groupStageObject.localizedMatchUpLabel(for: index)
}
switch displayStyle {
case .wide, .title:
return "Match \(indexInRound(in: matches) + 1)"
case .short:
return "#\(indexInRound(in: matches) + 1)"
}
}
func isSeedLocked(atTeamPosition teamPosition: TeamPosition) -> Bool {
return previousMatch(teamPosition)?.disabled == true
}
func unlockSeedPosition(atTeamPosition teamPosition: TeamPosition) {
previousMatch(teamPosition)?.enableMatch()
}
@discardableResult
func lockAndGetSeedPosition(atTeamPosition slot: TeamPosition?, opposingSeeding: Bool = false) -> Int {
let matchIndex = index
var teamPosition : TeamPosition {
if let slot {
return slot
} else {
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
}
}
previousMatch(teamPosition)?.disableMatch()
return matchIndex * 2 + teamPosition.rawValue
}
func isSeededBy(team: TeamRegistration, inTeamPosition teamPosition: TeamPosition) -> Bool {
guard let roundObject, roundObject.isUpperBracket() else { return false }
guard let bracketPosition = team.bracketPosition else { return false }
return index * 2 + teamPosition.rawValue == bracketPosition
}
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? {
let minutesToAdd = Double(matchFormat.getEstimatedDuration(additionalEstimationDuration))
return startDate?.addingTimeInterval(minutesToAdd * 60.0)
}
func winner() -> TeamRegistration? {
guard let winningTeamId else { return nil }
return self.tournamentStore.teamRegistrations.findById(winningTeamId)
}
func localizedStartDate() -> String {
if let startDate {
return startDate.formatted(date: .abbreviated, time: .shortened)
} else {
return ""
}
}
func scoreLabel() -> String {
if hasWalkoutTeam() == true {
return "WO"
}
let scoreOne = teamScore(.one)?.score?.components(separatedBy: ",") ?? []
let scoreTwo = teamScore(.two)?.score?.components(separatedBy: ",") ?? []
let tuples = zip(scoreOne, scoreTwo).map { ($0, $1) }
let scores = tuples.map { $0 + "/" + $1 }.joined(separator: " ")
return scores
}
func cleanScheduleAndSave(_ targetStartDate: Date? = nil) {
startDate = targetStartDate
confirmed = targetStartDate == nil ? false : true
endDate = nil
followingMatch()?.cleanScheduleAndSave(nil)
_loserMatch()?.cleanScheduleAndSave(nil)
do {
try self.tournamentStore.matches.addOrUpdate(instance: self)
} catch {
Logger.error(error)
}
}
func resetMatch() {
losingTeamId = nil
winningTeamId = nil
endDate = nil
removeCourt()
servingTeamId = nil
}
func resetScores() {
teamScores.forEach({ $0.score = nil })
do {
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores)
} catch {
Logger.error(error)
}
}
func teamWillBeWalkOut(_ team: TeamRegistration) {
resetMatch()
let existingTeamScore = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team)
existingTeamScore.walkOut = 1
do {
try self.tournamentStore.teamScores.addOrUpdate(instance: existingTeamScore)
} catch {
Logger.error(error)
}
}
func luckyLosers() -> [TeamRegistration] {
return roundObject?.previousRound()?.losers() ?? []
}
func isWalkOutSpot(_ teamPosition: TeamPosition) -> Bool {
return teamScore(teamPosition)?.walkOut == 1
}
func setLuckyLoser(team: TeamRegistration, teamPosition: TeamPosition) {
resetMatch()
let matchIndex = index
let position = matchIndex * 2 + teamPosition.rawValue
let previousScores = teamScores.filter({ $0.luckyLoser == position })
do {
try self.tournamentStore.teamScores.delete(contentOfs: previousScores)
} catch {
Logger.error(error)
}
let teamScoreLuckyLoser = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team)
teamScoreLuckyLoser.luckyLoser = position
do {
try self.tournamentStore.teamScores.addOrUpdate(instance: teamScoreLuckyLoser)
} catch {
Logger.error(error)
}
}
func disableMatch() {
_toggleMatchDisableState(true)
}
func enableMatch() {
_toggleMatchDisableState(false)
}
private func _loserMatch() -> Match? {
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: index)
return roundObject?.loserRounds().first?.getMatch(atMatchIndexInRound: indexInRound / 2)
}
func _toggleLoserMatchDisableState(_ state: Bool) {
guard let loserMatch = _loserMatch() else { return }
guard let next = _otherMatch() else { return }
loserMatch.byeState = true
if next.disabled {
loserMatch.byeState = false
}
loserMatch._toggleMatchDisableState(state, forward: true)
}
fileprivate func _otherMatch() -> Match? {
guard let round else { return nil }
guard index > 0 else { return nil }
let nextIndex = (index - 1) / 2
let topMatchIndex = (nextIndex * 2) + 1
let bottomMatchIndex = (nextIndex + 1) * 2
let isTopMatch = topMatchIndex + 1 == index
let lookingForIndex = isTopMatch ? topMatchIndex : bottomMatchIndex
return self.tournamentStore.matches.first(where: { $0.round == round && $0.index == lookingForIndex })
}
private func _forwardMatch(inRound round: Round) -> Match? {
guard let roundObjectNextRound = round.nextRound() else { return nil }
let nextIndex = (index - 1) / 2
return self.tournamentStore.matches.first(where: { $0.round == roundObjectNextRound.id && $0.index == nextIndex })
}
func _toggleForwardMatchDisableState(_ state: Bool) {
guard let roundObject else { return }
guard roundObject.parent != nil else { return }
guard let forwardMatch = _forwardMatch(inRound: roundObject) else { return }
guard let next = _otherMatch() else { return }
if next.disabled && byeState == false && next.byeState == false {
forwardMatch.byeState = false
forwardMatch._toggleMatchDisableState(state, forward: true)
} else if byeState && next.byeState {
print("don't disable forward match")
forwardMatch.byeState = false
forwardMatch._toggleMatchDisableState(false, forward: true)
} else {
forwardMatch.byeState = true
forwardMatch._toggleMatchDisableState(state, forward: true)
}
// if next.disabled == false {
// forwardMatch.byeState = state
// }
//
// if next.disabled == state {
// if next.byeState != byeState {
// //forwardMatch.byeState = state
// forwardMatch._toggleMatchDisableState(state)
// } else {
// forwardMatch._toggleByeState(state)
// }
// } else {
// }
// forwardMatch._toggleByeState(state)
}
func isSeededBy(team: TeamRegistration) -> Bool {
isSeededBy(team: team, inTeamPosition: .one) || isSeededBy(team: team, inTeamPosition: .two)
}
func isSeeded() -> Bool {
return isSeededAt(.one) || isSeededAt(.two)
}
func isSeededAt(_ teamPosition: TeamPosition) -> Bool {
if let team = team(teamPosition) {
return isSeededBy(team: team)
}
return false
}
func _toggleMatchDisableState(_ state: Bool, forward: Bool = false) {
//if disabled == state { return }
disabled = state
if state == true {
let teams = teams()
for team in teams {
if isSeededBy(team: team) {
team.bracketPosition = nil
do {
try self.tournamentStore.teamRegistrations.addOrUpdate(instance: team)
} catch {
Logger.error(error)
}
}
}
}
//byeState = false
do {
try self.tournamentStore.matches.addOrUpdate(instance: self)
} catch {
Logger.error(error)
}
_toggleLoserMatchDisableState(state)
if forward {
_toggleForwardMatchDisableState(state)
} else {
topPreviousRoundMatch()?._toggleMatchDisableState(state)
bottomPreviousRoundMatch()?._toggleMatchDisableState(state)
}
}
func next() -> Match? {
let matches: [Match] = self.tournamentStore.matches.filter { $0.round == round && $0.index > index }
return matches.sorted(by: \.index).first
}
func followingMatch() -> Match? {
guard let nextRoundId = roundObject?.nextRound()?.id else { return nil }
return getFollowingMatch(fromNextRoundId: nextRoundId)
}
func getFollowingMatch(fromNextRoundId nextRoundId: String) -> Match? {
return self.tournamentStore.matches.first(where: { $0.round == nextRoundId && $0.index == (index - 1) / 2 })
}
func getDuration() -> Int {
if let tournament = currentTournament() {
matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)
} else {
matchFormat.getEstimatedDuration()
}
}
func roundTitle() -> String? {
if groupStage != nil { return groupStageObject?.groupStageTitle() }
else if let roundObject { return roundObject.roundTitle() }
else { return nil }
}
func topPreviousRoundMatchIndex() -> Int {
return index * 2 + 1
}
func bottomPreviousRoundMatchIndex() -> Int {
return (index + 1) * 2
}
func topPreviousRoundMatch() -> Match? {
guard let roundObject else { return nil }
let topPreviousRoundMatchIndex = topPreviousRoundMatchIndex()
let roundObjectPreviousRoundId = roundObject.previousRound()?.id
return self.tournamentStore.matches.first(where: { match in
match.round != nil && match.round == roundObjectPreviousRoundId && match.index == topPreviousRoundMatchIndex
})
}
func bottomPreviousRoundMatch() -> Match? {
guard let roundObject else { return nil }
let bottomPreviousRoundMatchIndex = bottomPreviousRoundMatchIndex()
let roundObjectPreviousRoundId = roundObject.previousRound()?.id
return self.tournamentStore.matches.first(where: { match in
match.round != nil && match.round == roundObjectPreviousRoundId && match.index == bottomPreviousRoundMatchIndex
})
}
func previousMatch(_ teamPosition: TeamPosition) -> Match? {
if teamPosition == .one {
return topPreviousRoundMatch()
} else {
return bottomPreviousRoundMatch()
}
}
var computedOrder: Int {
guard let roundObject else { return index }
return roundObject.isLoserBracket() ? roundObject.index * 100 + indexInRound() : roundObject.index * 1000 + indexInRound()
}
func previousMatches() -> [Match] {
guard let roundObject else { return [] }
let roundObjectPreviousRoundId = roundObject.previousRound()?.id
return self.tournamentStore.matches.filter { match in
match.round == roundObjectPreviousRoundId && (match.index == topPreviousRoundMatchIndex() || match.index == bottomPreviousRoundMatchIndex())
}.sorted(by: \.index)
}
var matchFormat: MatchFormat {
get {
format ?? .defaultFormatForMatchType(.groupStage)
}
set {
format = newValue
}
}
func setWalkOut(_ teamPosition: TeamPosition) {
let teamScoreWalkout = teamScore(teamPosition) ?? TeamScore(match: id, team: team(teamPosition))
teamScoreWalkout.walkOut = 0
let teamScoreWinning = teamScore(teamPosition.otherTeam) ?? TeamScore(match: id, team: team(teamPosition.otherTeam))
teamScoreWinning.walkOut = nil
do {
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreWalkout, teamScoreWinning])
} catch {
Logger.error(error)
}
if endDate == nil {
endDate = Date()
}
winningTeamId = teamScoreWinning.teamRegistration
losingTeamId = teamScoreWalkout.teamRegistration
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
updateFollowingMatchTeamScore()
}
func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
updateScore(fromMatchDescriptor: matchDescriptor)
if endDate == nil {
endDate = Date()
}
if startDate == nil {
startDate = endDate?.addingTimeInterval(Double(-getDuration()*60))
}
let teamOne = team(matchDescriptor.winner)
let teamTwo = team(matchDescriptor.winner.otherTeam)
teamOne?.hasArrived()
teamTwo?.hasArrived()
winningTeamId = teamOne?.id
losingTeamId = teamTwo?.id
confirmed = true
groupStageObject?.updateGroupStageState()
roundObject?.updateTournamentState()
updateFollowingMatchTeamScore()
}
func updateScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) {
let teamScoreOne = teamScore(.one) ?? TeamScore(match: id, team: team(.one))
teamScoreOne.score = matchDescriptor.teamOneScores.joined(separator: ",")
let teamScoreTwo = teamScore(.two) ?? TeamScore(match: id, team: team(.two))
teamScoreTwo.score = matchDescriptor.teamTwoScores.joined(separator: ",")
do {
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo])
} catch {
Logger.error(error)
}
matchFormat = matchDescriptor.matchFormat
}
func updateFollowingMatchTeamScore() {
followingMatch()?.updateTeamScores()
_loserMatch()?.updateTeamScores()
}
func resetTeamScores(outsideOf newTeamScores: [TeamScore]) {
let ids = newTeamScores.map { $0.id }
let teamScores = teamScores.filter({ ids.contains($0.id) == false })
if teamScores.isEmpty == false {
do {
try self.tournamentStore.teamScores.delete(contentOfs: teamScores)
} catch {
Logger.error(error)
}
followingMatch()?.resetTeamScores(outsideOf: [])
_loserMatch()?.resetTeamScores(outsideOf: [])
}
}
func createTeamScores() -> [TeamScore] {
let teamOne = team(.one)
let teamTwo = team(.two)
let teams = [teamOne, teamTwo].compactMap({ $0 }).map { TeamScore(match: id, team: $0) }
return teams
}
func getOrCreateTeamScores() -> [TeamScore] {
let teamOne = team(.one)
let teamTwo = team(.two)
let teams = [teamOne, teamTwo].compactMap({ $0 }).map { teamScore(ofTeam: $0) ?? TeamScore(match: id, team: $0) }
return teams
}
func updateTeamScores() {
let teams = getOrCreateTeamScores()
do {
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
resetTeamScores(outsideOf: teams)
if teams.isEmpty == false {
updateFollowingMatchTeamScore()
}
}
func validateMatch(fromStartDate: Date, toEndDate: Date, fieldSetup: MatchFieldSetup) {
if hasEnded() == false {
startDate = fromStartDate
switch fieldSetup {
case .fullRandom:
if let _courtIndex = allCourts().randomElement() {
setCourt(_courtIndex)
}
case .random:
if let _courtIndex = availableCourts().randomElement() {
setCourt(_courtIndex)
}
case .field(let _courtIndex):
setCourt(_courtIndex)
}
} else {
startDate = fromStartDate
endDate = toEndDate
}
confirmed = true
}
func courtName() -> String? {
guard let courtIndex else { return nil }
if let courtName = currentTournament()?.courtName(atIndex: courtIndex) {
return courtName
} else {
return Court.courtIndexedTitle(atIndex: courtIndex)
}
}
func courtCount() -> Int {
return currentTournament()?.courtCount ?? 1
}
func courtIsAvailable(_ courtIndex: Int) -> Bool {
let courtUsed = currentTournament()?.courtUsed() ?? []
return courtUsed.contains(courtIndex) == false
}
func courtIsPreferred(_ courtIndex: Int) -> Bool {
return false
}
func allCourts() -> [Int] {
let availableCourts = Array(0..<courtCount())
return availableCourts
}
func availableCourts() -> [Int] {
let courtUsed = currentTournament()?.courtUsed() ?? []
return Array(Set(allCourts().map { $0 }).subtracting(Set(courtUsed)))
}
func removeCourt() {
courtIndex = nil
}
func setCourt(_ courtIndex: Int) {
self.courtIndex = courtIndex
}
func canBeStarted(inMatches matches: [Match]) -> Bool {
let teams = teamScores
guard teams.count == 2 else { return false }
guard hasEnded() == false else { return false }
guard hasStarted() == false else { return false }
return teams.compactMap({ $0.team }).allSatisfy({ $0.canPlay() && isTeamPlaying($0, inMatches: matches) == false })
}
func isTeamPlaying(_ team: TeamRegistration, inMatches matches: [Match]) -> Bool {
return matches.filter({ $0.teamScores.compactMap { $0.teamRegistration }.contains(team.id) }).isEmpty == false
}
var computedStartDateForSorting: Date {
return startDate ?? .distantFuture
}
var computedEndDateForSorting: Date {
return endDate ?? .distantFuture
}
func hasSpaceLeft() -> Bool {
return teamScores.count < 2
}
func isReady() -> Bool {
return teamScores.count >= 2
// teams().count == 2
}
func isEmpty() -> Bool {
return teamScores.isEmpty
// teams().isEmpty
}
func hasEnded() -> Bool {
return endDate != nil
}
func isGroupStage() -> Bool {
return groupStage != nil
}
func isBracket() -> Bool {
return round != nil
}
func walkoutTeam() -> [TeamRegistration] {
//walkout 0 means real walkout, walkout 1 means lucky loser situation
return scores().filter({ $0.walkOut == 0 }).compactMap { $0.team }
}
func hasWalkoutTeam() -> Bool {
return walkoutTeam().isEmpty == false
}
func currentTournament() -> Tournament? {
return groupStageObject?.tournamentObject() ?? roundObject?.tournamentObject()
}
func tournamentId() -> String? {
return groupStageObject?.tournament ?? roundObject?.tournament
}
func scores() -> [TeamScore] {
return self.tournamentStore.teamScores.filter { $0.match == id }
}
func teams() -> [TeamRegistration] {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func teams()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if groupStage != nil {
return [groupStageProjectedTeam(.one), groupStageProjectedTeam(.two)].compactMap { $0 }
}
guard let roundObject else { return [] }
let previousRound = roundObject.previousRound()
return [roundObject.roundProjectedTeam(.one, inMatch: self, previousRound: previousRound), roundObject.roundProjectedTeam(.two, inMatch: self, previousRound: previousRound)].compactMap { $0 }
// return [roundProjectedTeam(.one), roundProjectedTeam(.two)].compactMap { $0 }
}
func scoreDifference(_ teamPosition: Int) -> (set: Int, game: Int)? {
guard let teamScoreTeam = teamScore(.one), let teamScoreOtherTeam = teamScore(.two) else { return nil }
var reverseValue = 1
if teamPosition == team(.two)?.groupStagePosition {
reverseValue = -1
}
let endedSetsOne = teamScoreTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreTeam.isWalkOut())
let endedSetsTwo = teamScoreOtherTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreOtherTeam.isWalkOut())
var setDifference : Int = 0
let zip = zip(endedSetsOne, endedSetsTwo)
if matchFormat.setsToWin == 1 {
setDifference = endedSetsOne[0] - endedSetsTwo[0]
} else {
setDifference = zip.filter { $0 > $1 }.count - zip.filter { $1 > $0 }.count
}
let gameDifference = zip.map { ($0, $1) }.map { $0.0 - $0.1 }.reduce(0,+)
return (setDifference * reverseValue, gameDifference * reverseValue)
}
func groupStageProjectedTeam(_ team: TeamPosition) -> TeamRegistration? {
guard let groupStageObject else { return nil }
return groupStageObject.team(teamPosition: team, inMatchIndex: index)
}
func roundProjectedTeam(_ team: TeamPosition) -> TeamRegistration? {
guard let roundObject else { return nil }
let previousRound = roundObject.previousRound()
return roundObject.roundProjectedTeam(team, inMatch: self, previousRound: previousRound)
}
func teamWon(_ team: TeamRegistration?) -> Bool {
guard let winningTeamId else { return false }
return winningTeamId == team?.id
}
func teamWon(atPosition teamPosition: TeamPosition) -> Bool {
guard let winningTeamId else { return false }
return winningTeamId == team(teamPosition)?.id
}
func team(_ team: TeamPosition) -> TeamRegistration? {
#if _DEBUG_TIME //DEBUGING TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func match get team", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
if groupStage != nil {
return groupStageProjectedTeam(team)
} else {
return roundProjectedTeam(team)
}
}
func teamNames(_ team: TeamRegistration?) -> [String]? {
return team?.players().map { $0.playerLabel() }
}
func teamWalkOut(_ team: TeamRegistration?) -> Bool {
return teamScore(ofTeam: team)?.isWalkOut() == true
}
func teamScore(_ team: TeamPosition) -> TeamScore? {
return teamScore(ofTeam: self.team(team))
}
func teamScore(ofTeam team: TeamRegistration?) -> TeamScore? {
return scores().first(where: { $0.teamRegistration == team?.id })
}
func isRunning() -> Bool { // at least a match has started
return confirmed && hasStarted() && hasEnded() == false
}
func hasStarted() -> Bool { // meaning at least one match is over
if let startDate {
return startDate.timeIntervalSinceNow < 0
}
if hasEnded() {
return true
}
return false
//todo scores
// if let score {
// return score.hasEnded == false && score.sets.isEmpty == false
// } else {
// return false
// }
}
var roundObject: Round? {
guard let round else { return nil }
return self.tournamentStore.rounds.findById(round)
}
var groupStageObject: GroupStage? {
guard let groupStage else { return nil }
return self.tournamentStore.groupStages.findById(groupStage)
}
var isLoserBracket: Bool {
return roundObject?.parent != nil
}
enum CodingKeys: String, CodingKey {
case _id = "id"
case _round = "round"
case _groupStage = "groupStage"
case _startDate = "startDate"
case _endDate = "endDate"
case _index = "index"
case _format = "format"
// case _court = "court"
case _courtIndex = "courtIndex"
case _servingTeamId = "servingTeamId"
case _winningTeamId = "winningTeamId"
case _losingTeamId = "losingTeamId"
// case _broadcasted = "broadcasted"
case _name = "name"
// case _order = "order"
case _disabled = "disabled"
case _confirmed = "confirmed"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
if let round = round {
try container.encode(round, forKey: ._round)
} else {
try container.encodeNil(forKey: ._round)
}
if let groupStage = groupStage {
try container.encode(groupStage, forKey: ._groupStage)
} else {
try container.encodeNil(forKey: ._groupStage)
}
if let startDate = startDate {
try container.encode(startDate, forKey: ._startDate)
} else {
try container.encodeNil(forKey: ._startDate)
}
if let endDate = endDate {
try container.encode(endDate, forKey: ._endDate)
} else {
try container.encodeNil(forKey: ._endDate)
}
try container.encode(index, forKey: ._index)
if let format = format {
try container.encode(format, forKey: ._format)
} else {
try container.encodeNil(forKey: ._format)
}
if let servingTeamId = servingTeamId {
try container.encode(servingTeamId, forKey: ._servingTeamId)
} else {
try container.encodeNil(forKey: ._servingTeamId)
}
if let winningTeamId = winningTeamId {
try container.encode(winningTeamId, forKey: ._winningTeamId)
} else {
try container.encodeNil(forKey: ._winningTeamId)
}
if let losingTeamId = losingTeamId {
try container.encode(losingTeamId, forKey: ._losingTeamId)
} else {
try container.encodeNil(forKey: ._losingTeamId)
}
if let name = name {
try container.encode(name, forKey: ._name)
} else {
try container.encodeNil(forKey: ._name)
}
try container.encode(disabled, forKey: ._disabled)
if let courtIndex = courtIndex {
try container.encode(courtIndex, forKey: ._courtIndex)
} else {
try container.encodeNil(forKey: ._courtIndex)
}
try container.encode(confirmed, forKey: ._confirmed)
}
func insertOnServer() {
self.tournamentStore.matches.writeChangeAndInsertOnServer(instance: self)
for teamScore in self.teamScores {
try teamScore.insertOnServer()
}
}
}
enum MatchDateSetup: Hashable, Identifiable {
case inMinutes(Int)
case now
case customDate
var id: Int { hashValue }
}
enum MatchFieldSetup: Hashable, Identifiable {
case random
case fullRandom
// case firstAvailable
case field(Int)
var id: Int { hashValue }
}

@ -0,0 +1,740 @@
//
// 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
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 = true,
shouldEndRoundBeforeStartingNext: Bool = true,
groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false) {
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
}
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"
}
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) -> Date {
let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue()
let groupStages: [GroupStage] = tournament.groupStages()
let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount
let matches = groupStages.flatMap { $0._matches() }
matches.forEach({
$0.removeCourt()
$0.startDate = nil
$0.confirmed = false
})
var lastDate : Date = 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
lastDate = time
let groups = groupStages.filter({ $0.startDate == time })
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, 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(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, 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(numberOfCourtsAvailablePerRotation: Int, 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
// Use zip and flatMap to flatten matches in the desired order
let flattenedMatches = (0..<maxMatchesCount).flatMap { index in
_groupStages.compactMap { group in
// Use optional subscript to safely access matches
let playedMatches = group.playedMatches()
return playedMatches.indices.contains(index) ? playedMatches[index] : nil
}
}
var slots = [GroupStageTimeMatch]()
var availableMatchs = flattenedMatches
var rotationIndex = 0
var teamsPerRotation = [Int: [String]]()
var freeCourtPerRotation = [Int: [Int]]()
var groupLastRotation = [Int: Int]()
let courtsUnavailability = courtsUnavailability
while slots.count < flattenedMatches.count {
teamsPerRotation[rotationIndex] = []
freeCourtPerRotation[rotationIndex] = []
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }.map { ($0.groupIndex, 1) }
let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +)
var rotationMatches = Array(availableMatchs.filter({ match in
teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true
}).prefix(numberOfCourtsAvailablePerRotation))
if rotationIndex > 0 {
rotationMatches = rotationMatches.sorted(by: {
if counts[$0.groupStageObject!.index] ?? 0 == counts[$1.groupStageObject!.index] ?? 0 {
return $0.groupStageObject!.index < $1.groupStageObject!.index
} else {
return counts[$0.groupStageObject!.index] ?? 0 < counts[$1.groupStageObject!.index] ?? 0
}
})
}
(0..<numberOfCourtsAvailablePerRotation).forEach { courtIndex in
//print(mt.map { ($0.bracket!.index.intValue, counts[$0.bracket!.index.intValue]) })
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 courtIndex >= numberOfCourtsAvailablePerRotation - courtsUnavailable.count {
return false
} else {
return teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true
}
}) {
let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index)
slots.append(timeMatch)
teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds())
rotationMatches.removeAll(where: { $0.id == first.id })
availableMatchs.removeAll(where: { $0.id == first.id })
if let index = first.groupStageObject?.index {
groupLastRotation[index] = rotationIndex
}
} else {
freeCourtPerRotation[rotationIndex]!.append(courtIndex)
}
}
rotationIndex += 1
}
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(roundObject.roundTitle(), match.matchTitle())
if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate {
print("can't start \(targetedStartDate) earlier than \(roundStartDate)")
if targetedStartDate == minimumTargetedEndDate {
minimumTargetedEndDate = roundStartDate
} else {
minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate)
}
return false
}
let previousMatches = roundObject.precedentMatches(ofMatch: match)
if previousMatches.isEmpty { return true }
let previousMatchSlots = slots.filter({ slot in
previousMatches.map { $0.id }.contains(slot.matchID)
})
if previousMatchSlots.isEmpty {
if previousMatches.filter({ $0.disabled == false }).allSatisfy({ $0.startDate != nil }) {
return true
}
return false
}
if previousMatches.filter({ $0.disabled == false }).count > previousMatchSlots.count {
if previousMatches.filter({ $0.disabled == false }).anySatisfy({ $0.startDate != nil }) {
return true
}
return false
}
var includeBreakTime = false
if accountLoserBracketBreakTime && roundObject.isLoserBracket() {
includeBreakTime = true
}
if accountUpperBracketBreakTime && roundObject.isLoserBracket() == false {
includeBreakTime = true
}
let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex })
guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else {
return previousMatchIsInPreviousRotation
}
if targetedStartDate >= minimumPossibleEndDate {
if rotationDifferenceIsImportant {
return previousMatchIsInPreviousRotation
} else {
return true
}
} else {
if targetedStartDate == minimumTargetedEndDate {
minimumTargetedEndDate = minimumPossibleEndDate
} else {
minimumTargetedEndDate = min(minimumPossibleEndDate, 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(numberOfCourtsAvailablePerRotation: Int, 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
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 == false {
rotationIndex += 1
}
var freeCourtPerRotation = [Int: [Int]]()
let availableCourt = numberOfCourtsAvailablePerRotation
var courts = initialCourts ?? (0..<availableCourt).map { $0 }
var shouldStartAtDispatcherDate = rotationIndex > 0
while availableMatchs.count > 0 && issueFound == false {
freeCourtPerRotation[rotationIndex] = []
let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 })
var rotationStartDate: Date = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate
if shouldStartAtDispatcherDate {
rotationStartDate = dispatcherStartDate
shouldStartAtDispatcherDate = false
} else {
courts = rotationIndex == 0 ? courts : (0..<availableCourt).map { $0 }
}
courts.sort()
print("courts available at rotation \(rotationIndex)", courts)
print("rotationStartDate", rotationStartDate)
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], freeCourtPreviousRotation.count > 0 {
print("scenario where we are waiting for a breaktime to be over without any match to play in between or a free court was available and we need to recheck breaktime left on it")
let previousPreviousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) })
let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime)
let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false)
let noBreakAlreadyTested = previousRotationSlots.anySatisfy({ $0.startDate == previousEndDateNoBreak })
if let previousEndDate, let previousEndDateNoBreak {
let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate)
let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak)
print("difference w break", differenceWithBreak)
print("difference w/o break", differenceWithoutBreak)
let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60)
var difference = differenceWithBreak
if differenceWithBreak <= 0 {
difference = differenceWithoutBreak
} else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds {
difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak)
}
if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate {
courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index)
})
freeCourtPerRotation[rotationIndex] = courts
courts = freeCourtPreviousRotation
rotationStartDate = rotationStartDate.addingTimeInterval(-difference)
}
}
} else if let first = availableMatchs.first {
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.count == numberOfCourtsAvailablePerRotation {
print("issue")
issueFound = true
} else {
courts = Array(Set(courts).subtracting(Set(courtsUnavailable)))
}
}
dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
rotationIndex += 1
}
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])
}
}
return MatchDispatcher(timedMatches: slots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound)
}
func dispatchCourts(availableCourts: Int, courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) {
var matchPerRound = [String: Int]()
var minimumTargetedEndDate: Date = rotationStartDate
print("dispatchCourts", courts.sorted(), rotationStartDate, rotationIndex)
for (courtPosition, courtIndex) in courts.sorted().enumerated() {
if let first = availableMatchs.first(where: { match in
print("trying to find a match for \(courtIndex) in \(rotationIndex)")
let roundObject = match.roundObject!
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability)
print("courtsUnavailable \(courtsUnavailable)")
if courtPosition >= availableCourts - courtsUnavailable.count {
return false
}
let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate)
let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0
let roundMatchesCount = roundObject.playedMatches().count
if shouldHandleUpperRoundSlice {
print("shouldHandleUpperRoundSlice \(roundMatchesCount)")
if roundObject.parent == nil && roundMatchesCount > courts.count {
print("roundMatchesCount \(roundMatchesCount) > \(courts.count)")
if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) {
print("return false, \(currentRotationSameRoundMatches) >= \(min(roundMatchesCount / 2, courts.count))")
return false
}
}
}
//if all is ok, we do a final check to see if the first
let indexInRound = match.indexInRound()
print("Upper Round, index > 0, first Match of round \(indexInRound) and more than one court available; looking for next match (same round) \(indexInRound + 1)")
if roundObject.parent == nil && roundObject.index > 0, indexInRound == 0, let nextMatch = match.next() {
guard courtPosition < courts.count - 1, courts.count > 1 else {
print("next match and this match can not be played at the same time, returning false")
return false
}
if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) {
print("next match and this match can be played, returning true")
return true
}
}
//not adding a last match of a 4-match round (final not included obviously)
print("\(currentRotationSameRoundMatches) modulo \(currentRotationSameRoundMatches%2) same round match is even, index of round is not 0 and upper bracket. If it's not the last court available \(courtIndex) == \(courts.count - 1)")
if roundMatchesCount <= 4 && currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.parent == nil && ((courts.count > 1 && courtPosition >= courts.count - 1) || courts.count == 1 && availableCourts > 1) {
print("we return false")
return false
}
return canBePlayed
}) {
print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate)
if first.roundObject!.parent == nil {
if let roundIndex = matchPerRound[first.roundObject!.id] {
matchPerRound[first.roundObject!.id] = roundIndex + 1
} else {
matchPerRound[first.roundObject!.id] = 1
}
}
let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: first.matchFormat.breakTime.breakTime)
slots.append(timeMatch)
availableMatchs.removeAll(where: { $0.id == first.id })
} else {
freeCourtPerRotation[rotationIndex]!.append(courtIndex)
}
}
if freeCourtPerRotation[rotationIndex]!.count == availableCourts {
print("no match found to be put in this rotation, check if we can put anything to another date")
freeCourtPerRotation[rotationIndex] = []
let courtsUsed = getNextEarliestAvailableDate(from: slots)
var freeCourts: [Int] = []
if courtsUsed.isEmpty {
freeCourts = (0..<availableCourts).map { $0 }
} else {
freeCourts = courtsUsed.filter { (courtIndex, availableDate) in
availableDate <= minimumTargetedEndDate
}.sorted(by: \.1).map { $0.0 }
}
if let first = availableMatchs.first {
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration)
let courtsUnavailable = courtsUnavailable(startDate: minimumTargetedEndDate, duration: duration, courtsUnavailability: courtsUnavailability)
if courtsUnavailable.count < availableCourts {
dispatchCourts(availableCourts: availableCourts, courts: freeCourts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: minimumTargetedEndDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability)
}
}
}
}
@discardableResult func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool {
let upperRounds: [Round] = tournament.rounds()
let allMatches: [Match] = tournament.allMatches()
var rounds = [Round]()
if shouldEndRoundBeforeStartingNext {
rounds = upperRounds.flatMap {
[$0] + $0.loserRoundsAndChildren()
}
} else {
rounds = upperRounds.map {
$0
} + upperRounds.flatMap {
$0.loserRoundsAndChildren()
}
}
let flattenedMatches = rounds.flatMap { round in
round._matches().filter({ $0.disabled == 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(numberOfCourtsAvailablePerRotation: tournament.courtCount, 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 updateSchedule(tournament: Tournament) -> Bool {
var lastDate = tournament.startDate
if tournament.groupStageCount > 0 {
lastDate = updateGroupStageSchedule(tournament: tournament)
}
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)
}
}
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)
}
}

@ -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,101 @@
//
// 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
init(monthKey: String) {
self.monthKey = monthKey
self.creationDate = Date()
}
fileprivate func _updateCreationDate() {
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
}
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._updateCreationDate()
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"
}
}

@ -0,0 +1,574 @@
//
// 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
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) {
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
}
internal init(importedPlayer: ImportedPlayer) {
self.teamRegistration = ""
self.firstName = (importedPlayer.firstName ?? "").trimmed.capitalized
self.lastName = (importedPlayer.lastName ?? "").trimmed.uppercased()
self.licenceId = importedPlayer.license ?? nil
self.rank = Int(importedPlayer.rank)
self.sex = importedPlayer.male ? .male : .female
self.tournamentPlayed = importedPlayer.tournamentPlayed
self.points = importedPlayer.getPoints()
self.clubName = importedPlayer.clubName
self.ligueName = importedPlayer.ligueName
self.assimilation = importedPlayer.assimilation
self.source = .frenchFederation
}
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
firstName = _firstName
birthdate = federalData[2]
licenceId = federalData[3]
clubName = federalData[4]
let stringRank = federalData[5]
if stringRank.isEmpty {
rank = nil
} else {
rank = Int(stringRank)
}
let _email = federalData[6]
if _email.isEmpty == false {
self.email = _email
}
let _phoneNumber = federalData[7]
if _phoneNumber.isEmpty == false {
self.phoneNumber = _phoneNumber
}
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 components.count == 3 {
if let year = Calendar.current.dateComponents([.year], from: Date()).year, let age = components.last, let ageInt = Int(age) {
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 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())
}
}
func isPlaying() -> Bool {
team()?.isPlaying() == true
}
func contains(_ searchField: String) -> Bool {
firstName.localizedCaseInsensitiveContains(searchField) || lastName.localizedCaseInsensitiveContains(searchField)
}
func isSameAs(_ player: PlayerRegistration) -> Bool {
firstName.localizedCaseInsensitiveCompare(player.firstName) == .orderedSame &&
lastName.localizedCaseInsensitiveCompare(player.lastName) == .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 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 getRank() -> Int {
computedRank
}
@MainActor
func updateRank(from sources: [CSVParser], lastRank: Int) async throws {
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? {
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? {
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) {
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 {
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"
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: ._id)
if let teamRegistration = teamRegistration {
try container.encode(teamRegistration, forKey: ._teamRegistration)
} else {
try container.encodeNil(forKey: ._teamRegistration)
}
try container.encode(firstName, forKey: ._firstName)
try container.encode(lastName, forKey: ._lastName)
if let licenceId = licenceId {
try container.encode(licenceId, forKey: ._licenceId)
} else {
try container.encodeNil(forKey: ._licenceId)
}
if let rank = rank {
try container.encode(rank, forKey: ._rank)
} else {
try container.encodeNil(forKey: ._rank)
}
if let paymentType = paymentType {
try container.encode(paymentType, forKey: ._paymentType)
} else {
try container.encodeNil(forKey: ._paymentType)
}
if let sex = sex {
try container.encode(sex, forKey: ._sex)
} else {
try container.encodeNil(forKey: ._sex)
}
if let tournamentPlayed = tournamentPlayed {
try container.encode(tournamentPlayed, forKey: ._tournamentPlayed)
} else {
try container.encodeNil(forKey: ._tournamentPlayed)
}
if let points = points {
try container.encode(points, forKey: ._points)
} else {
try container.encodeNil(forKey: ._points)
}
if let clubName = clubName {
try container.encode(clubName, forKey: ._clubName)
} else {
try container.encodeNil(forKey: ._clubName)
}
if let ligueName = ligueName {
try container.encode(ligueName, forKey: ._ligueName)
} else {
try container.encodeNil(forKey: ._ligueName)
}
if let assimilation = assimilation {
try container.encode(assimilation, forKey: ._assimilation)
} else {
try container.encodeNil(forKey: ._assimilation)
}
if let phoneNumber = phoneNumber {
try container.encode(phoneNumber, forKey: ._phoneNumber)
} else {
try container.encodeNil(forKey: ._phoneNumber)
}
if let email = email {
try container.encode(email, forKey: ._email)
} else {
try container.encodeNil(forKey: ._email)
}
if let birthdate = birthdate {
try container.encode(birthdate, forKey: ._birthdate)
} else {
try container.encodeNil(forKey: ._birthdate)
}
try container.encode(computedRank, forKey: ._computedRank)
if let source = source {
try container.encode(source, forKey: ._source)
} else {
try container.encodeNil(forKey: ._source)
}
try container.encode(hasArrived, forKey: ._hasArrived)
}
enum PlayerDataSource: Int, Codable {
case frenchFederation = 0
case beachPadel = 1
}
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
case 1...10: return 400
case 11...30: return 1000
case 31...60: return 2000
case 61...100: return 3000
case 101...200: return 8000
case 201...500: return 12000
default:
return 15000
}
}
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()
}
}

@ -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
- 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
- S'il 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,755 @@
//
// 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?
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil) {
self.tournament = tournament
self.index = index
self.parent = parent
self.format = matchFormat
self.startDate = startDate
}
// 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 parent == nil {
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.tournament == tournament
&& $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.tournament == tournament
&& $0.bracketPosition != nil
&& ($0.bracketPosition! / 2) >= initialMatchIndex
&& ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches
}
}
func losers() -> [TeamRegistration] {
let teamIds: [String] = self._matches().compactMap { $0.losingTeamId }
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_TIME //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_TIME //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 indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
if isLoserBracket(), previousRound == nil, let parentRound = parentRound, let upperBracketTopMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2) {
return upperBracketTopMatch
}
return nil
}
func upperBracketBottomMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? {
#if _DEBUG_TIME //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 indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex)
if isLoserBracket(), previousRound == nil, let parentRound = parentRound, let upperBracketBottomMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) {
return upperBracketBottomMatch
}
return nil
}
func topPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? {
#if _DEBUG_TIME //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_TIME //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_TIME //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 parent == nil {
return enabledMatches()
} else {
return _matches()
}
}
func previousRound() -> Round? {
#if _DEBUG_TIME //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 })
}
var theoryCumulativeMatchCount: Int {
var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index)
if let parentRound {
totalMatches += parentRound.theoryCumulativeMatchCount
}
return totalMatches
}
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String {
#if _DEBUG_TIME //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
// })
let playedMatches = playedMatches()
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count)
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 _DEBUG_TIME //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 parent == nil {
if index == 0 { return SeedInterval(first: 1, last: 2) }
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index)
let seedsAfterThisRound : [TeamRegistration] = self.tournamentStore.teamRegistrations.filter {
$0.bracketPosition != nil
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex
}
let playedMatches = playedMatches()
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count)
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.parent == nil {
return parentRound.seedInterval(initialMode: initialMode)
}
return parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last
}
return nil
}
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String {
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_TIME //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
}
func isLoserBracket() -> Bool {
return parent != nil
}
func deleteLoserBracket() {
do {
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 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"
}
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)
if let parent = parent {
try container.encode(parent, forKey: ._parent)
} else {
try container.encodeNil(forKey: ._parent)
}
if let format = format {
try container.encode(format, forKey: ._format)
} else {
try container.encodeNil(forKey: ._format)
}
if let startDate = startDate {
try container.encode(startDate, forKey: ._startDate)
} else {
try container.encodeNil(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_TIME //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_TIME //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
}
}

@ -0,0 +1,649 @@
//
// 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?
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) {
self.tournament = tournament
self.groupStage = groupStage
self.registrationDate = registrationDate
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
}
var tournamentStore: TournamentStore {
return TournamentStore.instance(tournamentId: self.tournament)
}
// MARK: - Computed dependencies
func unsortedPlayers() -> [PlayerRegistration] {
return self.tournamentStore.playerRegistrations.filter { $0.teamRegistration == self.id }
}
// 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) {
let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: slot, opposingSeeding: opposingSeeding)
tournamentObject()?.resetTeamScores(in: bracketPosition)
self.bracketPosition = seedPosition
if groupStagePosition != nil && qualified == false {
qualified = true
}
tournamentObject()?.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 {
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())
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 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) -> 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())
}
}
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
}
}
}
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:
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())
}
}
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.tournamentStore.playerRegistrations.filter { $0.teamRegistration == self.id }.sorted { (lhs, rhs) in
let predicates: [AreInIncreasingOrder] = [
{ $0.sex?.rawValue ?? 0 < $1.sex?.rawValue ?? 0 },
{ $0.rank ?? 0 < $1.rank ?? 0 },
{ $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 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 tournamentObject() -> Tournament? {
return Store.main.findById(tournament)
}
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"
}
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)
if let groupStage = groupStage {
try container.encode(groupStage, forKey: ._groupStage)
} else {
try container.encodeNil(forKey: ._groupStage)
}
if let registrationDate = registrationDate {
try container.encode(registrationDate, forKey: ._registrationDate)
} else {
try container.encodeNil(forKey: ._registrationDate)
}
if let callDate = callDate {
try container.encode(callDate, forKey: ._callDate)
} else {
try container.encodeNil(forKey: ._callDate)
}
if let bracketPosition = bracketPosition {
try container.encode(bracketPosition, forKey: ._bracketPosition)
} else {
try container.encodeNil(forKey: ._bracketPosition)
}
if let groupStagePosition = groupStagePosition {
try container.encode(groupStagePosition, forKey: ._groupStagePosition)
} else {
try container.encodeNil(forKey: ._groupStagePosition)
}
if let comment = comment {
try container.encode(comment, forKey: ._comment)
} else {
try container.encodeNil(forKey: ._comment)
}
if let source = source {
try container.encode(source, forKey: ._source)
} else {
try container.encodeNil(forKey: ._source)
}
if let sourceValue = sourceValue {
try container.encode(sourceValue, forKey: ._sourceValue)
} else {
try container.encodeNil(forKey: ._sourceValue)
}
if let logo = logo {
try container.encode(logo, forKey: ._logo)
} else {
try container.encodeNil(forKey: ._logo)
}
if let name = name {
try container.encode(name, forKey: ._name)
} else {
try container.encodeNil(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)
if let lockedWeight = lockedWeight {
try container.encode(lockedWeight, forKey: ._lockedWeight)
} else {
try container.encodeNil(forKey: ._lockedWeight)
}
if let confirmationDate = confirmationDate {
try container.encode(confirmationDate, forKey: ._confirmationDate)
} else {
try container.encodeNil(forKey: ._confirmationDate)
}
try container.encode(qualified, forKey: ._qualified)
if let finalRanking {
try container.encode(finalRanking, forKey: ._finalRanking)
} else {
try container.encodeNil(forKey: ._finalRanking)
}
if let pointsEarned {
try container.encode(pointsEarned, forKey: ._pointsEarned)
} else {
try container.encodeNil(forKey: ._pointsEarned)
}
}
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,120 @@
//
// 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)
if let teamRegistration = teamRegistration {
try container.encode(teamRegistration, forKey: ._teamRegistration)
} else {
try container.encodeNil(forKey: ._teamRegistration)
}
//try container.encode(playerRegistrations, forKey: ._playerRegistrations)
if let score = score {
try container.encode(score, forKey: ._score)
} else {
try container.encodeNil(forKey: ._score)
}
if let walkOut = walkOut {
try container.encode(walkOut, forKey: ._walkOut)
} else {
try container.encodeNil(forKey: ._walkOut)
}
if let luckyLoser = luckyLoser {
try container.encode(luckyLoser, forKey: ._luckyLoser)
} else {
try container.encodeNil(forKey: ._luckyLoser)
}
}
func insertOnServer() {
self.tournamentStore.teamScores.writeChangeAndInsertOnServer(instance: self)
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,59 @@
//
// 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()
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_OPTIONS
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.loadCollectionsFromServerIfNoFile()
}
}

@ -0,0 +1,266 @@
//
// 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 deviceId: String?
init(username: String, email: String, firstName: String, lastName: String, phone: String?, country: String?) {
self.username = username
self.firstName = firstName
self.lastName = lastName
self.email = email
self.phone = phone
self.country = country
}
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 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"
}
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)
if let umpireCode = umpireCode {
try container.encode(umpireCode, forKey: ._umpireCode)
} else {
try container.encodeNil(forKey: ._umpireCode)
}
if let licenceId = licenceId {
try container.encode(licenceId, forKey: ._licenceId)
} else {
try container.encodeNil(forKey: ._licenceId)
}
try container.encode(firstName, forKey: ._firstName)
try container.encode(lastName, forKey: ._lastName)
if let phone = phone {
try container.encode(phone, forKey: ._phone)
} else {
try container.encodeNil(forKey: ._phone)
}
if let country = country {
try container.encode(country, forKey: ._country)
} else {
try container.encodeNil(forKey: ._country)
}
if let summonsMessageBody = summonsMessageBody {
try container.encode(summonsMessageBody, forKey: ._summonsMessageBody)
} else {
try container.encodeNil(forKey: ._summonsMessageBody)
}
if let summonsMessageSignature = summonsMessageSignature {
try container.encode(summonsMessageSignature, forKey: ._summonsMessageSignature)
} else {
try container.encodeNil(forKey: ._summonsMessageSignature)
}
if let summonsAvailablePaymentMethods = summonsAvailablePaymentMethods {
try container.encode(summonsAvailablePaymentMethods, forKey: ._summonsAvailablePaymentMethods)
} else {
try container.encodeNil(forKey: ._summonsAvailablePaymentMethods)
}
try container.encode(summonsDisplayFormat, forKey: ._summonsDisplayFormat)
try container.encode(summonsDisplayEntryFee, forKey: ._summonsDisplayEntryFee)
try container.encode(summonsUseFullCustomMessage, forKey: ._summonsUseFullCustomMessage)
if let matchFormatsDefaultDuration = matchFormatsDefaultDuration {
try container.encode(matchFormatsDefaultDuration, forKey: ._matchFormatsDefaultDuration)
} else {
try container.encodeNil(forKey: ._matchFormatsDefaultDuration)
}
if let bracketMatchFormatPreference = bracketMatchFormatPreference {
try container.encode(bracketMatchFormatPreference, forKey: ._bracketMatchFormatPreference)
} else {
try container.encodeNil(forKey: ._bracketMatchFormatPreference)
}
if let groupStageMatchFormatPreference = groupStageMatchFormatPreference {
try container.encode(groupStageMatchFormatPreference, forKey: ._groupStageMatchFormatPreference)
} else {
try container.encodeNil(forKey: ._groupStageMatchFormatPreference)
}
if let loserBracketMatchFormatPreference = loserBracketMatchFormatPreference {
try container.encode(loserBracketMatchFormatPreference, forKey: ._loserBracketMatchFormatPreference)
} else {
try container.encodeNil(forKey: ._loserBracketMatchFormatPreference)
}
if let deviceId {
try container.encode(deviceId, forKey: ._deviceId)
} else {
try container.encodeNil(forKey: ._deviceId)
}
}
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,51 @@
//
// 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) }
}
}
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)"
}
}
}

@ -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,26 @@
//
// 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
}
}

@ -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,234 @@
//
// 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 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))
}
}

@ -0,0 +1,25 @@
//
// FixedWidthInteger+Extensions.swift
// PadelClub
//
// Created by Razmig Sarkissian on 03/03/2024.
//
import Foundation
public extension FixedWidthInteger {
func ordinalFormattedSuffix() -> String {
switch self {
case 1: return "er"
default: return "ème"
}
}
func ordinalFormatted() -> String {
return self.formatted() + self.ordinalFormattedSuffix()
}
var pluralSuffix: String {
return self > 1 ? "s" : ""
}
}

@ -0,0 +1,23 @@
//
// 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()
}
}

@ -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,203 @@
//
// 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 {
return (self.count > length) ? self.prefix(length) + trailing : self
}
var trimmed: String {
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 result
}
}
// 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
}
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]) }
}
}
// 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)
try? string.write(to: url, atomically: true, encoding: .utf8)
return url
}
}
extension String {
func toInt() -> Int? {
Int(self)
}
}

@ -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,164 @@
//
// 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 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">
<li class="spacer" style="transform: translateY(-20px);">
&nbsp;{{roundLabel}}
<div>{{formatLabel}}</div>
</li>
<li class="spacer">&nbsp;{{roundLabel}}</li>
{{match-template}}
</ul>

@ -82,7 +82,6 @@ body{
<caption>
<h2>{{bracketTitle}}</h2>
<h3>{{bracketStartDate}}</h3>
<h3>{{formatLabel}}</h3>
</caption>
<tr>
<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}}
<div class="match-description-overlay" style="visibility:{{hidden}};">{{matchDescriptionTop}}</div>
</li>
<li class="game game-spacer" style="visibility:{{hidden}}">
<div class="center-match-overlay" style="visibility:{{hidden}};">{{centerMatchText}}</div>
</li>
<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 class="game game-spacer" style="visibility:{{hidden}}"><div class="multiline">{{matchDescription}}</div></li>
<li class="game game-bottom {{entrantTwoWon}}" style="visibility:{{hidden}}">
{{entrantTwo}}
</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">{{playerTwo}}<span>{{weightTwo}}</span></div>

@ -9,7 +9,6 @@
flex-direction:row;
padding: 1%;
}
.round{
display:flex;
flex-direction:column;
@ -28,7 +27,7 @@
.round .spacer{ flex-grow:1;
font-size:24px;
text-align: center;
color: #000000;
color: #bbb;
font-style:italic;
}
.round .spacer:first-child,
@ -66,7 +65,7 @@
li.game-spacer{
border-right:2px solid #4f7a38;
min-height:{{minHeight}}px;
min-height:156px;
text-align: right;
display : flex;
justify-content: center;
@ -92,40 +91,11 @@
overflow: hidden;
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>
</head>
<body>
<h3 style="visibility:{{titleHidden}}">{{tournamentTitle}} - {{tournamentStartDate}}</h3>
<h1>{{tournamentTitle}}</h1>
<main id="tournament">
{{brackets}}
</main>

@ -33,5 +33,7 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIFileSharingEnabled</key>
<true/>
</dict>
</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 LeStorage
import TipKit
import PadelClubData
@main
struct PadelClubApp: App {
@ -18,10 +17,6 @@ struct PadelClubApp: App {
@StateObject var dataStore = DataStore.shared
@State private var registrationError: RegistrationError? = nil
@State private var importObserverViewModel = ImportObserver()
@State private var showDisconnectionAlert: Bool = false
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@State var requiredVersion: String? = nil
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@ -53,109 +48,48 @@ struct PadelClubApp: App {
let dictionary = Bundle.main.infoDictionary!
let version = dictionary["CFBundleShortVersionString"] as! String
let build = dictionary["CFBundleVersion"] as! String
#if DEBUG
return "\(version) (\(build)) Debug"
#elseif TESTFLIGHT
return "\(version) (\(build)) TestFlight"
#elseif PRODTEST
return "\(version) (\(build)) ProdTest"
#else
return "\(version) (\(build))"
#endif
}
var body: some Scene {
WindowGroup {
if let requiredVersion {
DownloadNewVersionView(version: requiredVersion)
} else {
MainView()
.environment(\.horizontalSizeClass, .compact)
.alert(isPresented: presentError, error: registrationError) {
Button("Contactez-nous") {
_openMail()
}
Button("Annuler", role: .cancel) {
registrationError = nil
}
MainView()
.alert(isPresented: presentError, error: registrationError) {
Button("Contactez-nous") {
_openMail()
}
.onOpenURL { url in
#if targetEnvironment(simulator)
#else
_handleIncomingURL(url)
#endif
Button("Annuler", role: .cancel) {
registrationError = nil
}
.environmentObject(networkMonitor)
.environmentObject(dataStore)
.environment(importObserverViewModel)
.environment(navigationViewModel)
.accentColor(.master)
.onAppear {
self._checkVersion()
if ManualPatcher.patchIfPossible(.disconnect) == true {
self.showDisconnectionAlert = true
}
#if DEBUG
print("Running in Debug mode")
#elseif TESTFLIGHT
print("Running in TestFlight mode")
#elseif PRODTEST
print("Running in ProdTest mode")
}
.onOpenURL { url in
#if targetEnvironment(simulator)
#else
print("Running in Release mode")
_handleIncomingURL(url)
#endif
print(URLs.main.url)
networkMonitor.checkConnection()
self._onAppear()
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
}
}
}
}
.environmentObject(networkMonitor)
.environmentObject(dataStore)
.environment(importObserverViewModel)
.environment(navigationViewModel)
.accentColor(.master)
.onAppear {
networkMonitor.checkConnection()
self._onAppear()
}
.task {
//try? Tips.resetDatastore()
try? Tips.configure([
.displayFrequency(.immediate),
.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) {
// Parse the URL
let pathComponents = url.pathComponents
@ -193,11 +127,11 @@ struct PadelClubApp: App {
navigationViewModel.selectedTab = .umpire
}
if navigationViewModel.accountPath.isEmpty {
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
} else if navigationViewModel.accountPath.last! != .login {
navigationViewModel.accountPath.removeAll()
navigationViewModel.accountPath.append(MyAccountView.AccountScreen.login)
if navigationViewModel.umpirePath.isEmpty {
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
} else if navigationViewModel.umpirePath.last! != .login {
navigationViewModel.umpirePath.removeAll()
navigationViewModel.umpirePath.append(UmpireView.UmpireScreen.login)
}
}
}.resume()
@ -219,59 +153,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")
}

Binary file not shown.

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

@ -0,0 +1,224 @@
//
// CloudConvert.swift
// Padel Tournament
//
// Created by Razmig Sarkissian on 14/09/2023.
//
import Foundation
class CloudConvert {
enum CloudConvertionError: 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"
}
}
}
static let manager = CloudConvert()
func uploadFile(_ url: URL) async throws -> String {
let taskResponse = try await createJob(url)
let uploadResponse = try await uploadFile(taskResponse, url: url)
var fileReady = false
while fileReady == false {
try await Task.sleep(nanoseconds: 3_000_000_000)
let progressResponse = try await checkFile(id: uploadResponse.data.id)
if progressResponse.data.step == "finish" && progressResponse.data.stepPercent == 100 {
fileReady = true
print("progressResponse.data.minutes", progressResponse.data.minutes)
}
}
let convertedFile = try await downloadConvertedFile(id: uploadResponse.data.id)
return convertedFile
}
func createJob(_ url: URL) async throws -> TaskResponse {
guard let taskURL = URL(string: "https://api.convertio.co/convert") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert")
}
var request: URLRequest = URLRequest(url: taskURL)
let parameters = """
{"apikey":"d97cf13ef6d163e5e386c381fc8d256f","input":"upload","file":"","filename":"","outputformat":"csv","options":""}
"""
let postData = parameters.data(using: .utf8)
request.httpMethod = "POST"
request.httpBody = postData
let task = try await URLSession.shared.data(for: request)
//print("tried: \(request.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(TaskResponse.self, from: task.0)
}
func uploadFile(_ response: TaskResponse, url: URL) async throws -> UploadResponse {
guard let uploadTaskURL = URL(string: "https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)")
}
var uploadRequest: URLRequest = URLRequest(url: uploadTaskURL)
uploadRequest.httpMethod = "PUT"
let uploadTask = try await URLSession.shared.upload(for: uploadRequest, fromFile: url)
//print("tried: \(uploadRequest.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: uploadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(UploadResponse.self, from: uploadTask.0)
}
func checkFile(id: String) async throws -> ProgressResponse {
guard let taskURL = URL(string: "https://api.convertio.co/convert/\(id)/status") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/status")
}
var request: URLRequest = URLRequest(url: taskURL)
request.httpMethod = "GET"
let task = try await URLSession.shared.data(for: request)
//print("tried: \(request.url)")
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
return try JSONDecoder().decode(ProgressResponse.self, from: task.0)
}
func downloadConvertedFile(id: String) async throws -> String {
// try await Task.sleep(nanoseconds: 3_000_000_000)
guard let downloadTaskURL = URL(string: "https://api.convertio.co/convert/\(id)/dl/base64") else {
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/dl/base64")
}
var downloadRequest: URLRequest = URLRequest(url: downloadTaskURL)
downloadRequest.httpMethod = "GET"
let downloadTask = try await URLSession.shared.data(for: downloadRequest)
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: downloadTask.0) {
print("errorResponse.error", errorResponse.error)
throw CloudConvertionError.serviceError(errorResponse)
}
//print("tried: \(downloadRequest.url)")
let dataResponse = try JSONDecoder().decode(DataResponse.self, from: downloadTask.0)
if let decodedData = Data(base64Encoded: dataResponse.data.content), let string = String(data: decodedData, encoding: .utf8) {
return string
}
throw CloudConvertionError.unknownError
}
}
// MARK: - DataResponse
struct DataResponse: Decodable {
let code: Int
let status: String
let data: DataDownloadClass
}
// MARK: - DataClass
struct DataDownloadClass: Decodable {
let id, encode, content: String
}
// MARK: - ErrorResponse
struct ErrorResponse: Decodable {
let code: Int
let status, error: String
}
// MARK: - TaskResponse
struct TaskResponse: Decodable {
let code: Int
let status: String
let data: DataClass
}
// MARK: - DataClass
struct DataClass: Decodable {
let id: String
}
// MARK: - ProgressResponse
struct ProgressResponse: Decodable {
let code: Int
let status: String
let data: ProgressDataClass
}
// MARK: - DataClass
struct ProgressDataClass: Decodable {
let id, step: String
let stepPercent: Int
let minutes: String
enum CodingKeys: String, CodingKey {
case id, step
case stepPercent = "step_percent"
case minutes
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
step = try container.decode(String.self, forKey: .step)
minutes = try container.decode(String.self, forKey: .minutes)
if let value = try? container.decode(String.self, forKey: .stepPercent) {
print(value)
stepPercent = Int(value) ?? 0
} else {
stepPercent = try container.decode(Int.self, forKey: .stepPercent)
}
}
}
// MARK: - Output
struct Output: Decodable {
let url: String
let size: String
}
// MARK: - UploadResponse
struct UploadResponse: Decodable {
let code: Int
let status: String
let data: UploadDataClass
}
// MARK: - DataClass
struct UploadDataClass: Decodable {
let id, file: String
let size: Int
}
extension URL {
var encodedLastPathComponent: String {
if #available(iOS 17.0, *) {
lastPathComponent
} else {
lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? lastPathComponent
}
}
}

@ -0,0 +1,205 @@
//
// 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
}
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.\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 computedMessage: String {
[entryFeeMessage, message].compacted().map { $0.trimmed }.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,29 @@
//
// DisplayContext.swift
// PadelClub
//
// Created by Razmig Sarkissian on 20/03/2024.
//
import Foundation
enum DisplayContext {
case addition
case edition
case lockedForEditing
case selection
}
enum DisplayStyle {
case title
case wide
case short
}
enum MatchViewStyle {
case standardStyle // vue normal
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche
case feedStyle // vue programmation
case plainStyle // vue detail
case tournamentResultStyle //vue resultat tournoi
}

@ -0,0 +1,37 @@
//
// 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
var suffix: String {
switch self {
case .rawText:
return "txt"
case .csv:
return "csv"
}
}
func separator() -> String {
switch self {
case .rawText:
return " "
case .csv:
return ";"
}
}
func newLineSeparator(_ count: Int = 1) -> String {
return Array(repeating: "\n", count: count).joined()
}
}

@ -8,7 +8,6 @@
import Foundation
import LeStorage
import SwiftUI
import PadelClubData
enum FileImportManagerError: LocalizedError {
case unknownFormat
@ -29,6 +28,9 @@ class ImportObserver {
func currentlyImportingLabel() -> String {
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
}
@ -42,38 +44,31 @@ class ImportObserver {
class FileImportManager {
static let shared = FileImportManager()
func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) {
let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")]
var playersLeft = Dictionary(uniqueKeysWithValues: players.map { ($0.license, $0) })
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach { url in
if playersLeft.isEmpty { return }
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 playersLeft = players
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach({ url in
if playersLeft.isEmpty == false {
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
var lastName = federalPlayer.lastName
var firstName = federalPlayer.firstName
lastName.replace(characters: replacementsCharacters)
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
playersLeft.removeValue(forKey: license) // Remove processed player
playersLeft.forEach { importedPlayer in
if let federalPlayer = federalPlayers.first(where: { $0.license == importedPlayer.license }) {
var lastName = federalPlayer.lastName
lastName.replace(characters: replacementsCharacters)
var firstName = federalPlayer.firstName
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
}
}
playersLeft.removeAll(where: { $0.lastName.isEmpty == false })
}
}
players = Array(playersLeft.values)
})
}
func foundInWomenData(license: String?) -> Bool {
guard let license = license?.strippedLicense else {
return false
@ -132,16 +127,14 @@ class FileImportManager {
let weight: Int
let tournamentCategory: TournamentCategory
let tournamentAgeCategory: FederalTournamentAge
let tournamentLevel: TournamentLevel
let previousTeam: TeamRegistration?
var registrationDate: Date? = nil
var name: String? = nil
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, tournament: Tournament) {
self.players = Set(players)
self.tournamentCategory = tournamentCategory
self.tournamentAgeCategory = tournamentAgeCategory
self.tournamentLevel = tournamentLevel
self.name = name
self.previousTeam = previousTeam
if players.count < 2 {
@ -154,7 +147,7 @@ class FileImportManager {
}
let significantPlayerCount = 2
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,+)
} else {
self.weight = players.map { $0.computedRank }.reduce(0,+)
@ -185,7 +178,7 @@ class FileImportManager {
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) async throws -> [TeamHolder] {
switch fileProvider {
case .frenchFederation:
@ -193,9 +186,9 @@ class FileImportManager {
case .padelClub:
return await _getPadelClubTeams(from: fileContent, tournament: tournament)
case .custom:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: false, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: false, tournament: tournament)
case .customAutoSearch:
return await _getPadelBusinessLeagueTeams(from: fileContent, chunkByParameter: chunkByParameter, autoSearch: true, tournament: tournament)
return await _getPadelBusinessLeagueTeams(from: fileContent, autoSearch: true, tournament: tournament)
}
}
@ -285,9 +278,9 @@ class FileImportManager {
FederalTournamentAge.allCases.first(where: { $0.importingRawValue.canonicalVersion == ageCategory.canonicalVersion }) ?? .senior
}
let resultOne = Array(dataOne.dropFirst(3).dropLast(3))
let resultTwo = Array(dataTwo.dropFirst(3).dropLast(3))
let sexUnknown: Bool = (dataOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (dataTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
let resultOne = Array(dataOne.dropFirst(3).dropLast())
let resultTwo = Array(dataTwo.dropFirst(3).dropLast())
let sexUnknown: Bool = (resultOne.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true) || (resultTwo.last?.hasPrefix(FileImportManager.FFT_ASSIMILATION_WOMAN_IN_MAN) == true)
var sexPlayerOne : Int {
switch tournamentCategory {
@ -309,14 +302,12 @@ class FileImportManager {
if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled {
let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
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)
}
}
@ -372,14 +363,12 @@ class FileImportManager {
let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
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)
}
}
@ -410,7 +399,6 @@ class FileImportManager {
let registeredPlayers = found?.map({ importedPlayer in
let player = PlayerRegistration(importedPlayer: importedPlayer)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
return player
})
if let registeredPlayers, registeredPlayers.isEmpty == false {
@ -428,7 +416,7 @@ class FileImportManager {
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)
}
}
@ -436,7 +424,7 @@ class FileImportManager {
return results
}
private func _getPadelBusinessLeagueTeams(from fileContent: String, chunkByParameter: Bool, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
private func _getPadelBusinessLeagueTeams(from fileContent: String, autoSearch: Bool, tournament: Tournament) async -> [TeamHolder] {
let lines = fileContent.replacingOccurrences(of: "\"", with: "").components(separatedBy: "\n")
guard let firstLine = lines.first else { return [] }
var separator = ","
@ -446,84 +434,39 @@ class FileImportManager {
let fetchRequest = ImportedPlayer.fetchRequest()
let federalContext = PersistenceController.shared.localContainer.viewContext
var chunks: [[String]] = []
if chunkByParameter {
chunks = lines.chunked(byParameterAt: 1)
} else {
chunks = lines.chunked(into: 2)
}
let results = chunks.map { team in
let results: [TeamHolder] = lines.chunked(into: 2).map { team in
var teamName: String? = nil
let players = team.map { player in
let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerSexType = data[safe: 0] == "f" ? PlayerSexType.female : PlayerSexType.male
let lastName : String = data[safe: 2]?.trimmed ?? ""
let firstName : String = data[safe: 3]?.trimmed ?? ""
let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed
}
let phoneNumber : String? = data[safe: 4]?.trimmed.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines).prefixTrimmed(50)
let email : String? = data[safe: 5]?.prefixTrimmed(50)
let phoneNumber : String? = data[safe: 4]?.trimmed
let email : String? = data[safe: 5]?.trimmed
let rank : Int? = data[safe: 6]?.trimmed.toInt()
let licenceId : String? = data[safe: 7]?.prefixTrimmed(50)
let club : String? = data[safe: 8]?.prefixTrimmed(200)
let licenceId : String? = data[safe: 7]?.trimmed
let club : String? = data[safe: 8]?.trimmed
let predicate = NSPredicate(format: "firstName like[cd] %@ && lastName like[cd] %@", firstName, lastName)
fetchRequest.predicate = predicate
let found = try? federalContext.fetch(fetchRequest).first
if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player
} else {
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 TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: nil, name: teamName, tournament: tournament)
return TeamHolder(players: players, tournamentCategory: .men, tournamentAgeCategory: .senior, previousTeam: nil, name: teamName, tournament: tournament)
}
return results
}
}
extension Array where Element == String {
/// Groups the array of CSV lines based on the same value at the specified column index.
/// If no key is found, it defaults to chunking the array into groups of 2 lines.
/// - Parameter index: The index of the CSV column to group by.
/// - Returns: An array of arrays, where each inner array contains lines grouped by the CSV parameter or by default chunks of 2.
func chunked(byParameterAt index: Int) -> [[String]] {
var groups: [String: [String]] = [:]
for line in self {
let columns = line.split(separator: ";", omittingEmptySubsequences: false).map { String($0) }
if index < columns.count {
let key = columns[index]
if groups[key] == nil {
groups[key] = []
}
groups[key]?.append(line)
} else {
// Handle out-of-bounds by continuing
print("Warning: Index \(index) out of bounds for line: \(line)")
}
}
// If no valid groups found, chunk into groups of 2 lines
if groups.isEmpty {
return self.chunked(into: 2)
} else {
// Append groups by parameter value, converting groups.values into an array of arrays
return groups.map { $0.value }
}
}
}

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

@ -6,39 +6,12 @@
//
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 {
case template(tournament: Tournament)
case bracket(round: Round)
case loserBracket(upperRound: Round, hideTitle: Bool)
case loserBracket(upperRound: Round)
case match(match: Match)
case player(entrant: TeamRegistration)
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 {
fatalError()
}
@ -96,12 +69,12 @@ enum HtmlService {
}
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: bracket.tournamentObject()!.tournamentTitle(.short))
template = template.replacingOccurrences(of: "{{bracketTitle}}", with: bracket.groupStageTitle())
template = template.replacingOccurrences(of: "{{formatLabel}}", with: bracket.matchFormat.formatTitle())
var col = ""
var row = ""
bracket.teams().forEach { entrant in
col = col.appending(HtmlService.groupstageColumn(entrant: entrant, position: "col").html(options: options))
row = row.appending(HtmlService.groupstageRow(entrant: entrant, teamsPerBracket: bracket.size).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(headName: headName, withRank: withRank, withScore: withScore))
}
template = template.replacingOccurrences(of: "{{teamsCol}}", with: col)
template = template.replacingOccurrences(of: "{{teamsRow}}", with: row)
@ -109,15 +82,9 @@ enum HtmlService {
return template
case .groupstageEntrant(let entrant):
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] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -129,7 +96,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -141,7 +108,7 @@ enum HtmlService {
return template
case .groupstageRow(let entrant, let teamsPerBracket):
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 = ""
(0..<teamsPerBracket).forEach { index in
@ -150,38 +117,31 @@ enum HtmlService {
if shouldHide == false {
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)
return template
case .groupstageColumn(let entrant, let position):
var template = html
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
case .groupstageScore(let match, let shouldHide):
var template = html
if match == nil || options.withScore == false {
if match == nil || withScore == false {
template = template.replacingOccurrences(of: "{{winner}}", with: "")
template = template.replacingOccurrences(of: "{{score}}", with: "")
} else if let match, let winner = match.winner() {
template = template.replacingOccurrences(of: "{{winner}}", with: winner.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match.scoreLabel())
} else {
template = template.replacingOccurrences(of: "{{winner}}", with: match!.winner()!.teamLabel())
template = template.replacingOccurrences(of: "{{score}}", with: match!.scoreLabel())
}
template = template.replacingOccurrences(of: "{{hide}}", with: shouldHide ? "hide" : "")
return template
case .player(let entrant):
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] {
template = template.replacingOccurrences(of: "{{playerOne}}", with: playerOne.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "(\(playerOne.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightOne}}", with: "")
@ -193,7 +153,7 @@ enum HtmlService {
if let playerTwo = entrant.players()[safe: 1] {
template = template.replacingOccurrences(of: "{{playerTwo}}", with: playerTwo.playerLabel())
if options.withRank {
if withRank {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "(\(playerTwo.formattedRank()))")
} else {
template = template.replacingOccurrences(of: "{{weightTwo}}", with: "")
@ -204,33 +164,18 @@ enum HtmlService {
}
return template
case .hiddenPlayer:
var template = html + html
if options.withTeamIndex {
template += html
}
return template
return html + html
case .match(let match):
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) {
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(options: options))
if options.withScore, let top = match.topPreviousRoundMatch(), top.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionTop}}", with: [top.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{entrantOne}}", with: HtmlService.player(entrant: entrantOne).html(headName: headName, withRank: withRank, withScore: withScore))
} 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) {
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(options: options))
if options.withScore, let bottom = match.bottomPreviousRoundMatch(), bottom.hasEnded() {
template = template.replacingOccurrences(of: "{{matchDescriptionBottom}}", with: [bottom.scoreLabel(winnerFirst:true)].compactMap({ $0 }).joined(separator: "\n"))
}
template = template.replacingOccurrences(of: "{{entrantTwo}}", with: HtmlService.player(entrant: entrantTwo).html(headName: headName, withRank: withRank, withScore: withScore))
} 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 {
template = template.replacingOccurrences(of: "{{hidden}}", with: "hidden")
@ -243,45 +188,27 @@ enum HtmlService {
} else if match.teamWon(atPosition: .two) == true {
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: "{{matchDescriptionBottom}}", with: "")
template = template.replacingOccurrences(of: "{{centerMatchText}}", with: "")
template = template.replacingOccurrences(of: "{{matchDescription}}", with: "")
return template
case .bracket(let round):
var template = ""
var bracket = ""
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 = bracket.replacingOccurrences(of: "{{roundLabel}}", with: round.roundTitle())
bracket = bracket.replacingOccurrences(of: "{{formatLabel}}", with: round.matchFormat.formatTitle())
return bracket
case .loserBracket(let upperRound, let hideTitle):
case .loserBracket(let upperRound):
var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
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 = ""
for round in upperRound.loserRounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
}
let winnerName = ""
var winnerName = ""
let winner = """
<ul class="round" scope="last">
<li class="spacer">&nbsp;</li>
@ -294,35 +221,18 @@ enum HtmlService {
brackets = brackets.appending(winner)
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
case .template(let tournament):
var template = html
template = template.replacingOccurrences(of: "{{minHeight}}", with: options.withTeamIndex ? "226" : "156")
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.title))
template = template.replacingOccurrences(of: "{{tournamentStartDate}}", with: tournament.formattedDate())
template = template.replacingOccurrences(of: "{{tournamentTitle}}", with: tournament.tournamentTitle(.short))
var brackets = ""
for round in tournament.rounds() {
brackets = brackets.appending(HtmlService.bracket(round: round).html(options: options))
if options.includeLoserBracket {
if round.index == 1 {
let sub = HtmlService.loserBracket(upperRound: round, hideTitle: true).html(options: options)
template = template.appending(sub)
}
}
brackets = brackets.appending(HtmlService.bracket(round: round).html(headName: headName, withRank: withRank, withScore: withScore))
}
var winnerName = ""
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 = """
<ul class="round" scope="last">
@ -336,16 +246,6 @@ enum HtmlService {
brackets = brackets.appending(winner)
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
}
}

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

@ -16,18 +16,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
@Published var postalCode: String?
@Published var requestStarted: Bool = false
@Published var userReadableCityOrZipcode: String = ""
@Published var lastError: LocalizedError? = nil
enum LocationError: LocalizedError {
case unknownError(error: Error)
var errorDescription: String? {
switch self {
case .unknownError(let error):
return "Padel Club n'a pas réussi à vous localiser."
}
}
}
@Published var lastError: Error? = nil
override init() {
super.init()
@ -37,8 +26,6 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func requestLocation() {
lastError = nil
manager.requestLocation()
city = nil
location = nil
requestStarted = true
}
@ -62,7 +49,7 @@ class LocationManager: NSObject, ObservableObject, CLLocationManagerDelegate {
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("locationManager didFailWithError", error)
requestStarted = false
self.lastError = LocationError.unknownError(error: error)
self.lastError = error
}
func geocodeCity(cityOrZipcode: String, completion: @escaping (_ placemark: [CLPlacemark]?, _ error: Error?) -> Void) {

@ -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 CoreLocation
import PadelClubData
class NetworkFederalService {
struct HttpCommand: Decodable {
@ -34,40 +33,19 @@ class NetworkFederalService {
return decoder
}()
func runTenupTask<T: Decodable>(request: URLRequest) async throws -> T {
let (data, response) = try await URLSession.shared.data(for: request)
// Print request info
print("Request: \(request.httpMethod ?? "GET") \(request.url?.absoluteString ?? "")")
// 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)")
func runTenupTask<T:Decodable>(request: URLRequest) async throws -> T {
let task = try await URLSession.shared.data(for: request)
if request.httpMethod == "PUT" {
print("tried PUT: \(request.url!)")
if let urlResponse = task.1 as? HTTPURLResponse {
print(urlResponse.statusCode)
}
}
// Now try to decode
do {
return try tenupJsonDecoder.decode(T.self, from: data)
} catch {
print("Decoding error: \(error)")
throw error
}
return try tenupJsonDecoder.decode(T.self, from: task.0)
}
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",
@ -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"
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("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")
@ -115,11 +93,105 @@ class NetworkFederalService {
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")
func getUmpireData(idTournament: String) async throws -> (name: String?, email: String?, phone: String?) {
return try await FederalDataService.shared.getUmpireData(idTournament: idTournament)
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")
}
}
}

@ -6,7 +6,6 @@
//
import Foundation
import PadelClubData
class NetworkManager {
static let shared: NetworkManager = NetworkManager()
@ -52,9 +51,9 @@ class NetworkManager {
let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory
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")
if FileManager.default.fileExists(atPath: destinationFileUrl.path()), let modificationDate = destinationFileUrl.creationDate() {
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"
}
}
}

@ -1,83 +0,0 @@
//
// XlsToCsvService.swift
// PadelClub
//
// Created by razmig on 12/04/2025.
//
import Foundation
import LeStorage
class XlsToCsvService {
static func exportToCsv(url: URL) async throws -> String {
let service = try StoreCenter.main.service()
var request = try service._baseRequest(servicePath: "xls-to-csv/", method: .post, requiresToken: true)
// Create the boundary string for multipart/form-data
let boundary = UUID().uuidString
// Set the content type to multipart/form-data with the boundary
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
// The file to upload
let fileName = url.lastPathComponent
let fileURL = url
// Construct the body of the request
var body = Data()
// Start the body with the boundary and content-disposition for the file
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: application/vnd.ms-excel\r\n\r\n".data(using: .utf8)!)
// Append the file data
if let fileData = try? Data(contentsOf: fileURL) {
body.append(fileData)
}
// End the body with the boundary
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
// Set the body of the request
request.httpBody = body
let (data, response) = try await URLSession.shared.data(for: request)
// Check the response status code
if let httpResponse = response as? HTTPURLResponse {
print("Status code: \(httpResponse.statusCode)")
}
// Convert the response data to a String
if let responseString = String(data: data, encoding: .utf8) {
return responseString
} else {
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide")
throw ConvertionError.serviceError(error)
}
}
}
struct ErrorResponse: Decodable {
let code: Int
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,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,119 @@
//
// 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
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()
}
}
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 URLs.api.rawValue == "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()
}
}
}
}

@ -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,257 @@
//
// 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(players: [FederalPlayer], sourceFileType: SourceFile, date: Date) {
let lastDateString = URL.importDateFormatter.string(from: date)
let dateString = ["CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].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)
}
}
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 PadelClubData
typealias LineIterator = AsyncLineSequence<URL.AsyncBytes>.AsyncIterator
struct Line: Identifiable {
@ -71,18 +70,18 @@ struct Line: Identifiable {
struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
typealias Element = Line
let url: URL
private let url: URL
private var lineIterator: LineIterator
private let separator: Character
private let seperator: Character
private let quoteCharacter: Character = "\""
private var lineNumber = 0
private let date: Date
let maleData: Bool
init(url: URL, separator: Character = ";") {
init(url: URL, seperator: Character = ";") {
self.date = url.dateFromPath
self.url = url
self.separator = separator
self.seperator = seperator
self.lineIterator = url.lines.makeAsyncIterator()
self.maleData = url.path().contains(SourceFile.messieurs.rawValue)
}
@ -128,7 +127,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
func makeAsyncIterator() -> CSVParser {
return self
}
private func split(line: String) -> [String?] {
var data = [String?]()
var inQuote = false
@ -140,7 +139,7 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
inQuote = !inQuote
continue
case separator:
case seperator:
if !inQuote {
data.append(currentString.isEmpty ? nil : currentString)
currentString = ""
@ -158,63 +157,4 @@ struct CSVParser: AsyncSequence, AsyncIteratorProtocol {
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 TipKit
import PadelClubData
struct PadelBeachExportTip: Tip {
var title: Text {
@ -22,7 +21,7 @@ struct PadelBeachExportTip: Tip {
var image: Image? {
Image(systemName: "square.and.arrow.up")
}
var actions: [Action] {
Action(id: "more-info-export", title: "En savoir plus")
Action(id: "beach-padel", title: "beach-padel.app.fft.fr")
@ -43,7 +42,7 @@ struct PadelBeachImportTip: Tip {
var image: Image? {
Image(systemName: "square.and.arrow.down")
}
var actions: [Action] {
Action(id: "more-info-import", title: "Importer le fichier excel beach-padel")
}
@ -62,8 +61,8 @@ struct GenerateLoserBracketTip: Tip {
var image: Image? {
nil
}
var actions: [Action] {
Action(id: "generate-loser-bracket", title: "Générer les matchs de classements")
}
@ -84,7 +83,7 @@ struct TeamChampionshipTip: Tip {
var image: Image? {
Image(systemName: "person.3")
}
var actions: [Action] {
Action(id: "list-manager", title: "Ouvrir le gestionnaire d'équipe")
}
@ -105,7 +104,7 @@ struct TeamChampionshipMainScreenTip: Tip {
var image: Image? {
Image(systemName: "arrow.uturn.backward")
}
var actions: [Action] {
Action(id: "set-list-manager-main", title: "Afficher sur l'écran principal")
}
@ -194,7 +193,7 @@ struct InscriptionManagerWomanRankTip: Tip {
var image: Image? {
Image(systemName: "figure.dress.line.vertical.figure")
}
var title: Text {
Text("Rang d'une joueuse dans un tournoi messieurs")
}
@ -214,7 +213,7 @@ struct InscriptionManagerRankUpdateTip: Tip {
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.")
}
var image: Image? {
Image(systemName: "list.number")
}
@ -233,7 +232,7 @@ struct SharePictureTip: Tip {
var message: Text? {
Text("Lors d'un partage d'une photo, le texte est disponible dans le presse-papier du téléphone")
}
var image: Image? {
Image(systemName: "photo.badge.checkmark.fill")
}
@ -247,7 +246,7 @@ struct NewRankDataAvailableTip: Tip {
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.")
}
var image: Image? {
Image(systemName: "exclamationmark.icloud")
}
@ -267,7 +266,7 @@ struct ClubSearchTip: Tip {
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.")
}
var image: Image? {
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.searchCity.rawValue, title: "Chercher une ville")
}
enum ActionKey: String {
case searchAroundMe = "search-around-me"
case searchCity = "search-city"
@ -292,7 +291,7 @@ struct SlideToDeleteTip: Tip {
var message: Text? {
Text("Vous pouvez effacer un club en glissant votre doigt vers la gauche")
}
var image: Image? {
Image(systemName: "trash")
}
@ -307,7 +306,7 @@ struct MultiTournamentsEventTip: Tip {
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.")
}
var image: Image? {
Image(systemName: "trophy.circle")
}
@ -321,7 +320,7 @@ struct NotFoundAreWalkOutTip: Tip {
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")
}
var image: Image? {
Image(systemName: "person.2.slash.fill")
}
@ -339,7 +338,7 @@ struct TournamentPublishingTip: Tip {
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.")
}
var image: Image? {
Image("PadelClub_logo_fondclair_transparent")
}
@ -353,7 +352,7 @@ struct TournamentTVBroadcastTip: Tip {
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.")
}
var image: Image? {
Image(systemName: "sparkles.tv")
}
@ -362,7 +361,7 @@ struct TournamentTVBroadcastTip: Tip {
struct TournamentSelectionTip: Tip {
@Parameter
static var tournamentCount: Int? = nil
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -380,7 +379,7 @@ struct TournamentSelectionTip: Tip {
var message: Text? {
return Text("Vous pouvez appuyer sur la barre de navigation pour accéder à un tournoi de votre événement.")
}
var image: Image? {
Image(systemName: "filemenu.and.selection")
}
@ -389,7 +388,7 @@ struct TournamentSelectionTip: Tip {
struct TournamentRunningTip: Tip {
@Parameter
static var isRunning: Bool = false
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -407,7 +406,7 @@ struct TournamentRunningTip: Tip {
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.")
}
var image: Image? {
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 !"
return Text(.init(message))
}
var image: Image? {
Image(systemName: "person.crop.circle")
}
var actions: [Action] {
Action(id: ActionKey.createAccount.rawValue, title: "Créer votre compte")
//todo
//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: "Jeter un oeil au site Padel Club")
}
enum ActionKey: String {
case createAccount = "createAccount"
case learnMore = "learnMore"
@ -444,7 +443,7 @@ struct CreateAccountTip: Tip {
struct SlideToDeleteSeedTip: Tip {
@Parameter
static var seeds: Int = 0
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -462,7 +461,7 @@ struct SlideToDeleteSeedTip: Tip {
var message: Text? {
Text("Vous pouvez retirer une tête de série de sa position en glissant votre doigt vers la gauche")
}
var image: Image? {
Image(systemName: "person.fill.xmark")
}
@ -471,7 +470,7 @@ struct SlideToDeleteSeedTip: Tip {
struct PrintTip: Tip {
@Parameter
static var seeds: Int = 0
var rules: [Rule] {
[
// Define a rule based on the app state.
@ -481,7 +480,7 @@ struct PrintTip: Tip {
}
]
}
var title: Text {
Text("Coup d'oeil de votre tableau")
}
@ -489,7 +488,7 @@ struct PrintTip: Tip {
var message: Text? {
Text("Vous pouvez avoir un aperçu de votre tableau ou l'imprimer.")
}
var image: Image? {
Image(systemName: "printer")
}
@ -506,9 +505,9 @@ struct PrintTip: Tip {
struct BracketEditTip: Tip {
@Parameter
static var matchesHidden: Int = 0
var nextRoundName: String?
var rules: [Rule] {
[
// 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"
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? {
Image(systemName: "rectangle.slash")
}
}
struct TeamsExportTip: Tip {
var title: Text {
Text("Exporter les paires")
}
@ -544,143 +543,18 @@ struct TeamsExportTip: Tip {
var message: Text? {
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? {
Image(systemName: "sparkles")
}
}
struct PlayerTournamentSearchTip: Tip {
var title: Text {
Text("Cherchez un tournoi autour de vous !")
}
var message: Text? {
Text("Padel Club facilite la recherche de tournois et l'inscription !")
}
var image: Image? {
Image(systemName: "trophy.circle")
}
var actions: [Action] {
Action(id: ActionKey.selectAction.rawValue, title: "Éssayer")
}
enum ActionKey: String {
case selectAction = "selectAction"
}
}
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")
Image(systemName: "square.and.arrow.up")
}
}
struct TipStyleModifier: ViewModifier {
@Environment(\.colorScheme) var colorScheme
var tint: Color?
var background: Color?
var asSection: Bool
func body(content: Content) -> some View {
if asSection {
Section {
@ -690,7 +564,7 @@ struct TipStyleModifier: ViewModifier {
preparedContent(content: content)
}
}
@ViewBuilder
func preparedContent(content: Content) -> some View {
if let background {

@ -0,0 +1,76 @@
//
// 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/"
#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"
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
}
}

@ -7,8 +7,6 @@
import Foundation
import SwiftUI
import TipKit
import PadelClubData
enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
var id: Int { self.rawValue }
@ -20,27 +18,20 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
case activity
case history
case tenup
case around
enum ViewStyle {
case list
case calendar
}
var localizedTitleKey: String {
switch self {
case .activity:
return "À venir"
return "En cours"
case .history:
return "Terminé"
case .tenup:
return "Tenup"
case .around:
return "Autour"
}
}
func associatedTip() -> (any Tip)? {
switch self {
case .around:
return nil //PlayerTournamentSearchTip()
default:
return nil
}
}
@ -48,26 +39,25 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
localizedTitleKey
}
func systemImage() -> String? {
var systemImage: String {
switch self {
case .around:
return "location.magnifyingglass"
default:
return nil
case .activity:
return "squares.leading.rectangle"
case .history:
return "book.closed"
case .tenup:
return "tennisball"
}
}
func badgeValue() -> Int? {
switch self {
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:
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:
FederalDataViewModel.shared.filteredFederalTournaments.map { $0.tournaments.count }.reduce(0,+)
case .around:
nil
}
}
@ -94,25 +84,6 @@ enum AgendaDestination: Int, CaseIterable, Identifiable, Selectable, Equatable {
} else {
return nil
}
case .around:
return nil
}
}
}
enum ViewStyle {
case list
case calendar
}
struct ViewStyleKey: EnvironmentKey {
static let defaultValue: ViewStyle = .list
}
extension EnvironmentValues {
var viewStyle: ViewStyle {
get { self[ViewStyleKey.self] }
set { self[ViewStyleKey.self] = newValue }
}
}

@ -7,54 +7,32 @@
import SwiftUI
import LeStorage
import PadelClubData
@Observable
class FederalDataViewModel {
static let shared = FederalDataViewModel()
var federalTournaments: [FederalTournament] = []
var searchedFederalTournaments: [FederalTournament] = []
var levels: Set<TournamentLevel> = Set()
var categories: Set<TournamentCategory> = Set()
var ageCategories: Set<FederalTournamentAge> = Set()
var selectedClubs: Set<String> = Set()
var id: UUID = UUID()
var searchAttemptCount: Int = 0
var dayDuration: Int?
var dayPeriod: DayPeriod = .all
var weekdays: Set<Int> = Set()
var lastError: NetworkManagerError?
func filterStatus() -> String {
var labels: [String] = []
labels.append(contentsOf: levels.map { $0.localizedLevelLabel() }.formatList())
labels.append(contentsOf: categories.map { $0.localizedCategoryLabel() }.formatList())
labels.append(contentsOf: ageCategories.map { $0.localizedFederalAgeLabel() }.formatList())
labels.append(contentsOf: levels.map { $0.localizedLabel() })
labels.append(contentsOf: categories.map { $0.localizedLabel() })
labels.append(contentsOf: ageCategories.map { $0.localizedLabel() })
let clubNames = selectedClubs.compactMap { codeClub in
let club: Club? = DataStore.shared.clubs.first(where: { $0.code == codeClub })
return club?.clubTitle(.short)
}
labels.append(contentsOf: clubNames.formatList())
labels.append(contentsOf: weekdays.map { Date.weekdays[$0 - 1] }.formatList())
if dayPeriod != .all {
labels.append(dayPeriod.localizedDayPeriodLabel())
}
if let dayDuration {
labels.append("max " + dayDuration.formatted() + " jour" + dayDuration.pluralSuffix)
}
labels.append(contentsOf: clubNames)
return labels.joined(separator: ", ")
}
var searchedClubs: [FederalClub] {
searchedFederalTournaments.compactMap { ft in
ft.federalClub
}.uniqued { fc in
fc.federalClubCode
}.sorted(by: \.federalClubName)
}
func selectedClub() -> Club? {
if selectedClubs.isEmpty == false {
return DataStore.shared.clubs.first(where: { $0.code == selectedClubs.first! })
@ -68,71 +46,25 @@ class FederalDataViewModel {
categories.removeAll()
ageCategories.removeAll()
selectedClubs.removeAll()
dayPeriod = .all
dayDuration = nil
weekdays.removeAll()
id = UUID()
}
func areFiltersEnabled() -> Bool {
(weekdays.isEmpty && levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty && dayPeriod == .all && dayDuration == nil) == false
}
var filteredFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: federalTournaments)
(levels.isEmpty && categories.isEmpty && ageCategories.isEmpty && selectedClubs.isEmpty) == false
}
var filteredSearchedFederalTournaments: [FederalTournamentHolder] {
filteredFederalTournaments(from: searchedFederalTournaments)
}
func filteredFederalTournaments(from tournaments: [any FederalTournamentHolder]) -> [FederalTournamentHolder] {
tournaments.filter({ tournament in
var filteredFederalTournaments: [FederalTournament] {
federalTournaments.filter({ tournament in
(levels.isEmpty || tournament.tournaments.anySatisfy({ levels.contains($0.level) }))
&&
(categories.isEmpty || tournament.tournaments.anySatisfy({ categories.contains($0.category) }))
&&
(ageCategories.isEmpty || tournament.tournaments.anySatisfy({ ageCategories.contains($0.age) }))
&&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
})
}
func countForTournamentBuilds(from tournaments: [any FederalTournamentHolder]) -> Int {
tournaments.filter({ tournament in
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
})
.flatMap { $0.tournaments }
.filter {
(levels.isEmpty || levels.contains($0.level))
&&
(categories.isEmpty || categories.contains($0.category))
&&
(ageCategories.isEmpty || ageCategories.contains($0.age))
}
.count
}
func buildIsValid(_ build: any TournamentBuildHolder) -> Bool {
(levels.isEmpty || levels.contains(build.level))
&&
(categories.isEmpty || categories.contains(build.category))
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
}
func isTournamentValidForFilters(_ tournament: Tournament) -> Bool {
if tournament.isDeleted { return false }
let firstPart = (levels.isEmpty || levels.contains(tournament.level))
@ -140,13 +72,7 @@ class FederalDataViewModel {
(categories.isEmpty || categories.contains(tournament.category))
&&
(ageCategories.isEmpty || ageCategories.contains(tournament.age))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
if let codeClub = tournament.club()?.code {
return firstPart && (selectedClubs.isEmpty || selectedClubs.contains(codeClub))
} else {
@ -161,59 +87,19 @@ class FederalDataViewModel {
&&
(ageCategories.isEmpty || ageCategories.contains(build.age))
&&
(selectedClubs.isEmpty || (tournament.codeClub != nil && selectedClubs.contains(tournament.codeClub!)))
&&
(dayPeriod == .all || (dayPeriod != .all && dayPeriod == tournament.dayPeriod))
&&
(dayDuration == nil || (dayDuration != nil && dayDuration == tournament.dayDuration))
&&
(weekdays.isEmpty || weekdays.contains(tournament.startDate.weekDay))
(selectedClubs.isEmpty || selectedClubs.contains(tournament.codeClub!))
}
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()
func gatherTournaments(clubs: [Club], startDate: Date, endDate: Date? = nil) async throws {
try await clubs.filter { $0.code != nil }.concurrentForEach { club in
let newTournaments = try await FederalDataService.shared.getClubFederalTournaments(
page: 0,
tournaments: [],
club: club.name,
codeClub: club.code!,
startDate: startDate,
endDate: endDate
)
let newTournaments = try await NetworkFederalService.shared.getClubFederalTournaments(page: 0, tournaments: [], club: club.name, codeClub: club.code!, startDate: startDate, endDate: endDate)
// Safely add to collector
await collector.add(tournaments: newTournaments.tournaments)
}
// 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)
newTournaments.forEach { tournament in
if self.federalTournaments.contains(where: { $0.id == tournament.id }) == false {
self.federalTournaments.append(tournament)
}
}
}
}
}
struct FederalClub: Identifiable {
var id: String { federalClubCode }
var federalClubCode: String
var federalClubName: String
}

@ -0,0 +1,101 @@
//
// MatchDescriptor.swift
// PadelClub
//
// Created by Razmig Sarkissian on 02/04/2024.
//
import Foundation
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?
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.valueTeamOne }.map { "\($0)" }
}
var teamTwoScores: [String] {
setDescriptors.compactMap { $0.valueTeamTwo }.map { "\($0)" }
}
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
}

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

Loading…
Cancel
Save