Compare commits

..

24 Commits
sync3 ... main

Author SHA1 Message Date
Laurent 3df4b37e52 add cache example 4 days ago
Razmig Sarkissian 21359c2f56 fix ongoing 2 weeks ago
Razmig Sarkissian f0f04e9f64 add a way to filter out tournament in ongoing view 2 weeks ago
Razmig Sarkissian 4d3679f95c build 4 2 weeks ago
Razmig Sarkissian 7608122bac fix groupstage 6 size ordering 2 weeks ago
Razmig Sarkissian c2f58e93da fix F format 2 weeks ago
Razmig Sarkissian e994d17946 Merge remote-tracking branch 'refs/remotes/origin/main' 2 weeks ago
Razmig Sarkissian 6152c92d9a fix issue with p25 format 2 weeks ago
Razmig Sarkissian 25563982bb fix issue with p25 format 3 weeks ago
Razmig Sarkissian dd7207ae33 small improvements 3 weeks ago
Razmig Sarkissian 617bb91d6a fix ranking warning 3 weeks ago
Razmig Sarkissian 66abaeeb58 fix send all by event 3 weeks ago
Razmig Sarkissian 9f66c3a769 add a call all event method 3 weeks ago
Razmig Sarkissian a17f3dee1c improve call team view 3 weeks ago
Razmig Sarkissian d64ee6a7e0 add global search 3 weeks ago
Razmig Sarkissian 4842ad4572 improve export data capability for teams / players 4 weeks ago
Razmig Sarkissian 4fc4c2b41c add custom club name option in tournament for calling teams 4 weeks ago
Razmig Sarkissian d321634069 fix head manager match count 4 weeks ago
Razmig Sarkissian 01e455d8b5 fix points won issue 4 weeks ago
Razmig Sarkissian df010931bc fix stuff 4 weeks ago
Razmig Sarkissian 4454a2c2d6 fix stuff headmanager 4 weeks ago
Razmig Sarkissian 54a35571b1 add heads config system 4 weeks ago
Razmig Sarkissian ecf14eaa32 couple of fixes 1 month ago
Razmig Sarkissian 1ef3faf5a2 add format selection to horaire and format view 1 month ago
  1. 4
      PadelClubData/Data/DataStore.swift
  2. 28
      PadelClubData/Data/Event.swift
  3. 9
      PadelClubData/Data/Gen/BaseTournament.swift
  4. 5
      PadelClubData/Data/Gen/Tournament.json
  5. 18
      PadelClubData/Data/GroupStage.swift
  6. 12
      PadelClubData/Data/Match.swift
  7. 42
      PadelClubData/Data/PlayerRegistration.swift
  8. 24
      PadelClubData/Data/Round.swift
  9. 62
      PadelClubData/Data/TeamRegistration.swift
  10. 71
      PadelClubData/Data/Tournament.swift
  11. 12
      PadelClubData/Extensions/Date+Extensions.swift
  12. 93
      PadelClubData/Extensions/String+Extensions.swift
  13. 95
      PadelClubData/Utils/ContactManager.swift
  14. 20
      PadelClubData/Utils/ExportFormat.swift
  15. 67
      PadelClubData/ViewModel/PadelRule.swift

@ -332,7 +332,7 @@ public class DataStore: ObservableObject {
_lastRunningAndNextCheckDate = nil
}
public func runningAndNextMatches() -> [Match] {
public func runningAndNextMatches(_ selectedTournaments: Set<String> = Set()) -> [Match] {
let dateNow : Date = Date()
if let lastCheck = _lastRunningAndNextCheckDate,
let cachedMatches = _cachedRunningAndNextMatches,
@ -340,7 +340,7 @@ public class DataStore: ObservableObject {
return cachedMatches
}
let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
let lastTournaments = self.tournaments.filter { (selectedTournaments.isEmpty || selectedTournaments.contains($0.id) == false) && $0.isDeleted == false && $0.startDate <= dateNow.addingTimeInterval(86_400) && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10)
var runningMatches: [Match] = []
for tournament in lastTournaments {

@ -184,6 +184,34 @@ final public class Event: BaseEvent {
return link.compactMap({ $0 }).joined(separator: "\n\n")
}
public func selectedTeams() -> [TeamRegistration] {
confirmedTournaments().flatMap({ $0.selectedSortedTeams() })
}
public func umpireMail() -> [String]? {
confirmedTournaments().first?.umpireMail()
}
public func mailSubject() -> String {
let tournaments = confirmedTournaments()
guard !tournaments.isEmpty else {
return eventTitle()
}
// Get the date range from all confirmed tournaments
let dates = tournaments.compactMap { $0.startDate }
guard let firstDate = dates.min(), let lastDate = dates.max() else {
return eventTitle()
}
let dateRange = firstDate == lastDate
? firstDate.formattedDate(.short)
: "\(firstDate.formatted(.dateTime.day())) au \(lastDate.formatted(.dateTime.day())) \(lastDate.formatted(.dateTime.month(.wide))) \(lastDate.formatted(.dateTime.year()))"
let subject = [eventTitle(), dateRange, clubObject()?.name].compactMap({ $0 }).joined(separator: " | ")
return subject
}
func insertOnServer() throws {
DataStore.shared.events.writeChangeAndInsertOnServer(instance: self)

@ -85,6 +85,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
public var clubMemberFeeDeduction: Double? = nil
public var unregisterDeltaInHours: Int = 24
public var currencyCode: String? = nil
public var customClubName: String? = nil
public init(
id: String = Store.randomId(),
@ -158,7 +159,8 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
showTeamsInProg: Bool = false,
clubMemberFeeDeduction: Double? = nil,
unregisterDeltaInHours: Int = 24,
currencyCode: String? = nil
currencyCode: String? = nil,
customClubName: String? = nil
) {
super.init()
self.id = id
@ -233,6 +235,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
self.clubMemberFeeDeduction = clubMemberFeeDeduction
self.unregisterDeltaInHours = unregisterDeltaInHours
self.currencyCode = currencyCode
self.customClubName = customClubName
}
required public override init() {
super.init()
@ -313,6 +316,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
case _clubMemberFeeDeduction = "clubMemberFeeDeduction"
case _unregisterDeltaInHours = "unregisterDeltaInHours"
case _currencyCode = "currencyCode"
case _customClubName = "customClubName"
}
private static func _decodePayment(container: KeyedDecodingContainer<CodingKeys>) throws -> TournamentPayment? {
@ -456,6 +460,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
self.clubMemberFeeDeduction = try container.decodeIfPresent(Double.self, forKey: ._clubMemberFeeDeduction) ?? nil
self.unregisterDeltaInHours = try container.decodeIfPresent(Int.self, forKey: ._unregisterDeltaInHours) ?? 24
self.currencyCode = try container.decodeIfPresent(String.self, forKey: ._currencyCode) ?? nil
self.customClubName = try container.decodeIfPresent(String.self, forKey: ._customClubName) ?? nil
try super.init(from: decoder)
}
@ -533,6 +538,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
try container.encode(self.clubMemberFeeDeduction, forKey: ._clubMemberFeeDeduction)
try container.encode(self.unregisterDeltaInHours, forKey: ._unregisterDeltaInHours)
try container.encode(self.currencyCode, forKey: ._currencyCode)
try container.encode(self.customClubName, forKey: ._customClubName)
try super.encode(to: encoder)
}
@ -615,6 +621,7 @@ public class BaseTournament: SyncedModelObject, SyncedStorable {
self.clubMemberFeeDeduction = tournament.clubMemberFeeDeduction
self.unregisterDeltaInHours = tournament.unregisterDeltaInHours
self.currencyCode = tournament.currencyCode
self.customClubName = tournament.customClubName
}
public static func parentRelationships() -> [Relationship] {

@ -372,6 +372,11 @@
"name": "currencyCode",
"type": "String",
"optional": true
},
{
"name": "customClubName",
"type": "String",
"optional": true
}
]
}

@ -337,7 +337,7 @@ final public class GroupStage: BaseGroupStage, SideStorable {
case 5:
order = [3, 5, 8, 2, 6, 1, 9, 4, 7, 0]
case 6:
order = [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0]
order = [4, 6, 10, 1, 8, 12, 2, 7, 11, 3, 5, 13, 14, 9, 0]
case 7:
order = [6, 15, 20, 1, 16, 19, 2, 10, 18, 3, 9, 14, 4, 7, 12, 5, 8, 11, 0, 13, 17]
case 8:
@ -629,6 +629,22 @@ final public class GroupStage: BaseGroupStage, SideStorable {
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
}
public func setData(from correspondingGroupStage: GroupStage, tournamentStartDate: Date, previousTournamentStartDate: Date) {
self.matchFormat = correspondingGroupStage.matchFormat
if let correspondingPlannedStartDate = correspondingGroupStage.plannedStartDate {
let offset = correspondingPlannedStartDate.timeIntervalSince(previousTournamentStartDate)
self.startDate = tournamentStartDate.addingTimeInterval(offset)
}
self.size = correspondingGroupStage.size
self.name = correspondingGroupStage.name
let matches = correspondingGroupStage._matches()
for (index, match) in self._matches().enumerated() {
match.setData(from: matches[index], tournamentStartDate: tournamentStartDate, previousTournamentStartDate: previousTournamentStartDate)
}
}
public override func deleteDependencies(store: Store, actionOption: ActionOption) {
store.deleteDependencies(type: Match.self, actionOption: actionOption) { $0.groupStage == self.id }
}

@ -1140,6 +1140,18 @@ defer {
plannedStartDate ?? startDate
}
public func setData(from correspondingMatch: Match, tournamentStartDate: Date, previousTournamentStartDate: Date) {
if let correspondingMatchPlannedStartDate = correspondingMatch.plannedStartDate {
let offset = correspondingMatchPlannedStartDate.timeIntervalSince(previousTournamentStartDate)
self.startDate = tournamentStartDate.addingTimeInterval(offset)
}
self.disabled = correspondingMatch.disabled
self.matchFormat = correspondingMatch.matchFormat
self.courtIndex = correspondingMatch.courtIndex
self.name = correspondingMatch.name
}
func insertOnServer() {
self.tournamentStore?.matches.writeChangeAndInsertOnServer(instance: self)
for teamScore in self.teamScores {

@ -56,12 +56,23 @@ final public class PlayerRegistration: BasePlayerRegistration, SideStorable {
return nil
}
public func pasteData(_ exportFormat: ExportFormat = .rawText) -> String {
switch exportFormat {
case .rawText:
return [firstName.capitalized, lastName.capitalized, licenceId?.computedLicense].compactMap({ $0 }).joined(separator: exportFormat.separator())
case .csv:
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator())
public func pasteData(_ exportFormat: ExportFormat = .rawText, type: ExportType) -> String {
switch type {
case .payment:
switch exportFormat {
case .rawText:
return [firstName.capitalized, lastName.capitalized, hasPaidOnline() ? "Payé [X]" : "Payé  [ ]"].compactMap({ $0 }).joined(separator: exportFormat.separator())
case .csv:
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator())
}
case .sharing:
switch exportFormat {
case .rawText:
return [firstName.capitalized, lastName.capitalized, licenceId?.computedLicense].compactMap({ $0 }).joined(separator: exportFormat.separator())
case .csv:
return [lastName.uppercased() + " " + firstName.capitalized, hasPaid() ? "Payé" : "", hasPaidOnline() ? "En ligne" : ""]
.joined(separator: exportFormat.separator())
}
}
}
@ -70,6 +81,13 @@ final public class PlayerRegistration: BasePlayerRegistration, SideStorable {
}
public func contains(_ searchField: String) -> Bool {
if let paymentId, paymentId.localizedCaseInsensitiveContains(searchField) { return true }
if let email, email.lowercased().localizedCaseInsensitiveContains(searchField) { return true }
if let contactEmail, contactEmail.localizedCaseInsensitiveContains(searchField) { return true }
if let licenceId, licenceId.localizedCaseInsensitiveContains(searchField) { return true }
if searchField.isPhoneNumber(), let phoneNumber, phoneNumber.isSamePhoneNumber(as: searchField) { return true }
if searchField.isPhoneNumber(), let contactPhoneNumber, contactPhoneNumber.isSamePhoneNumber(as: searchField) { return true }
let nameComponents = searchField.canonicalVersion.split(separator: " ")
if nameComponents.count > 1 {
@ -167,7 +185,7 @@ final public class PlayerRegistration: BasePlayerRegistration, SideStorable {
}
public func setComputedRank(in tournament: Tournament) {
let maleUnranked = tournament.unrankValue(for: isMalePlayer()) ?? 90_415
let maleUnranked = tournament.unrankValue(for: isMalePlayer()) ?? 92_327
let femaleUnranked = tournament.unrankValue(for: false) ?? 0
let currentRank = rank ?? maleUnranked
switch tournament.tournamentCategory {
@ -212,6 +230,16 @@ final public class PlayerRegistration: BasePlayerRegistration, SideStorable {
registrationStatus = .confirmed
}
public func hasMail() -> Bool {
let mails = [email, contactEmail].compactMap({ $0 })
return mails.isEmpty == false && mails.anySatisfy({ $0.isValidEmail() })
}
public func hasMobilePhone() -> Bool {
let phones = [phoneNumber, contactPhoneNumber].compactMap({ $0 })
return phones.isEmpty == false && phones.anySatisfy({ $0.isPhoneNumber() })
}
public func paidAmount(_ tournament: Tournament, accountForGiftOrForfeit: Bool = false) -> Double {
if accountForGiftOrForfeit == false, paymentType == .gift {
return 0.0

@ -56,6 +56,30 @@ final public class Round: BaseRound, SideStorable {
return tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true }
}
public func setData(from correspondingRound: Round, tournamentStartDate: Date, previousTournamentStartDate: Date) {
let matches = correspondingRound._matches()
for (index, match) in self._matches().enumerated() {
match.setData(from: matches[index], tournamentStartDate: tournamentStartDate, previousTournamentStartDate: previousTournamentStartDate)
}
self.matchFormat = correspondingRound.matchFormat
if let correspondingPlannedStartDate = correspondingRound.plannedStartDate {
let offset = correspondingPlannedStartDate.timeIntervalSince(previousTournamentStartDate)
self.startDate = tournamentStartDate.addingTimeInterval(offset)
}
self.loserBracketMode = correspondingRound.loserBracketMode
self.groupStageLoserBracket = correspondingRound.groupStageLoserBracket
loserRounds().forEach { round in
if let pRound = correspondingRound.loserRounds().first(where: { r in
r.index == round.index
}) {
round.setData(from: pRound, tournamentStartDate: tournamentStartDate, previousTournamentStartDate: previousTournamentStartDate)
}
}
}
// MARK: -
public var matchFormat: MatchFormat {

@ -171,7 +171,7 @@ final public class TeamRegistration: BaseTeamRegistration, SideStorable {
} else if let roundMatchStartDate = initialMatch()?.startDate {
return roundMatchStartDate
}
return nil
return callDate
}
public var initialWeight: Int {
@ -378,14 +378,20 @@ final public class TeamRegistration: BaseTeamRegistration, SideStorable {
resetBracketPosition()
}
public func pasteData(_ exportFormat: ExportFormat = .rawText, _ index: Int = 0) -> String {
public func pasteData(_ exportFormat: ExportFormat = .rawText, type: ExportType, _ index: Int = 0) -> String {
switch exportFormat {
case .rawText:
return [playersPasteData(exportFormat), formattedInscriptionDate(exportFormat), name]
.compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator())
switch type {
case .sharing:
return [playersPasteData(exportFormat, type: type), formattedInscriptionDate(exportFormat), name]
.compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator())
case .payment:
return [playersPasteData(exportFormat, type: type), name]
.compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator())
}
case .csv:
return [
index.formatted(), playersPasteData(exportFormat),
index.formatted(), playersPasteData(exportFormat, type: type),
isWildCard() ? "WC" : weight.formatted(),
].joined(separator: exportFormat.separator())
}
@ -435,15 +441,20 @@ final public class TeamRegistration: BaseTeamRegistration, SideStorable {
}
}
public func playersPasteData(_ exportFormat: ExportFormat = .rawText) -> String {
public func playersPasteData(_ exportFormat: ExportFormat = .rawText, type: ExportType) -> String {
switch exportFormat {
case .rawText:
return players().map { $0.pasteData(exportFormat) }.joined(
return players().map { $0.pasteData(exportFormat, type: type) }.joined(
separator: exportFormat.newLineSeparator())
case .csv:
return players().map {
[$0.pasteData(exportFormat), isWildCard() ? "WC" : $0.computedRank.formatted()]
.joined(separator: exportFormat.separator())
switch type {
case .sharing:
[$0.pasteData(exportFormat, type: type), isWildCard() ? "WC" : $0.computedRank.formatted()]
.joined(separator: exportFormat.separator())
case .payment:
$0.pasteData(exportFormat, type: type)
}
}.joined(separator: exportFormat.separator())
}
}
@ -540,7 +551,7 @@ final public class TeamRegistration: BaseTeamRegistration, SideStorable {
}
public func unrankValue(for malePlayer: Bool) -> Int {
return tournamentObject()?.unrankValue(for: malePlayer) ?? 90_415
return tournamentObject()?.unrankValue(for: malePlayer) ?? 92_327
}
public func groupStageObject() -> GroupStage? {
@ -677,6 +688,37 @@ final public class TeamRegistration: BaseTeamRegistration, SideStorable {
return nil
}
public func followingMatches() -> [Match] {
guard let tournamentStore else { return [] }
let allTeamScores = tournamentStore.teamScores.filter({ $0.teamRegistration == self.id })
let ids = allTeamScores.compactMap({ $0.match })
let matches = tournamentStore.matches.filter({ match in
ids.contains(match.id)
})
return matches.sorted(by: \.computedStartDateForSorting)
}
public func nextMatch(in followingMatches: [Match]) -> Match? {
return followingMatches.filter({ $0.hasEnded() == false }).first
}
public func lastMatchPlayed(in followingMatches: [Match]) -> Match? {
return followingMatches.first(where: { $0.hasEnded() })
}
public func numberOfRotation(in followingMatches: [Match]) -> (Int, Int)? {
if let nextMatch = nextMatch(in: followingMatches), let nextMatchPlannedStartDate = nextMatch.plannedStartDate, let lastMatchPlayed = lastMatchPlayed(in: followingMatches), let lastMatchPlayedPlannedStartDate = lastMatchPlayed.plannedStartDate {
let courtCount = self.tournamentStore?.matches.filter({ $0.plannedStartDate == nextMatchPlannedStartDate && $0.disabled == false && $0.hasEnded() == false && $0.confirmed == true && $0.id != nextMatch.id && $0.hasStarted() == false }).count ?? 0
let interval = nextMatchPlannedStartDate.timeIntervalSince(lastMatchPlayedPlannedStartDate)
let matchDuration = lastMatchPlayed.matchFormat.defaultEstimatedDuration * 60
let rotation = Int(interval) / matchDuration
print("numberOfRotation", interval, matchDuration, courtCount, rotation)
return (rotation, (rotation - 1) * lastMatchPlayed.courtCount() + courtCount + 1)
}
return nil
}
func insertOnServer() {
self.tournamentStore?.teamRegistrations.writeChangeAndInsertOnServer(instance: self)
for playerRegistration in self.unsortedPlayers() {

@ -125,10 +125,16 @@ final public class Tournament: BaseTournament {
public func groupStages(atStep step: Int = 0) -> [GroupStage] {
guard let tournamentStore = self.tournamentStore else { return [] }
let groupStages: [GroupStage] = tournamentStore.groupStages.filter { $0.tournament == self.id && $0.step == step }
let groupStages: [GroupStage] = tournamentStore.groupStages.filter { $0.step == step }
return groupStages.sorted(by: \.index)
}
public func hasGroupeStages() -> Bool {
if groupStageCount > 0 { return true }
guard let tournamentStore = self.tournamentStore else { return false }
return tournamentStore.groupStages.isEmpty == false
}
public func allGroupStages() -> [GroupStage] {
guard let tournamentStore = self.tournamentStore else { return [] }
return tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder)
@ -271,17 +277,24 @@ defer {
return Store.main.findById(event)
}
public func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText) -> String {
public func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText, type: ExportType) -> String {
let _selectedSortedTeams = selectedSortedTeams()
let selectedSortedTeams = _selectedSortedTeams + waitingListSortedTeams(selectedSortedTeams: _selectedSortedTeams)
switch exportFormat {
case .rawText:
return (selectedSortedTeams.compactMap { $0.pasteData(exportFormat) } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true).compactMap { $0.pasteData(exportFormat) }).joined(separator: exportFormat.newLineSeparator(2))
let waitingList = waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true)
var stats = [String]()
if type == .payment, isAnimation(), minimumPlayerPerTeam == 1 {
stats += ["\(self.selectedPlayers().count.formatted()) personnes"]
} else {
stats += [selectedSortedTeams.count.formatted() + " équipes"]
}
return (stats + selectedSortedTeams.compactMap { $0.pasteData(exportFormat, type: type) } + (waitingList.isEmpty == false ? ["Liste d'attente"] : []) + waitingList.compactMap { $0.pasteData(exportFormat, type: type) }).joined(separator: exportFormat.newLineSeparator(1))
case .csv:
let headers = ["", "Nom Prénom", "rang", "Nom Prénom", "rang", "poids", "Paire"].joined(separator: exportFormat.separator())
var teamPaste = [headers]
for (index, team) in selectedSortedTeams.enumerated() {
var teamData = team.pasteData(exportFormat, index + 1)
var teamData = team.pasteData(exportFormat, type: type, index + 1)
teamData.append(exportFormat.separator())
teamData.append(team.teamLastNames().joined(separator: " / "))
teamPaste.append(teamData)
@ -708,7 +721,7 @@ defer {
var groupStageTeamCount: Int = groupStageSpots - wcGroupStage.count
if groupStageTeamCount < 0 { groupStageTeamCount = 0 }
if bracketSeeds < 0 { bracketSeeds = 0 }
let clubName = self.clubName
if prioritizeClubMembers {
var bracketTeams: [TeamRegistration] = []
@ -1209,14 +1222,7 @@ defer {
}
public func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
case .title:
startDate.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).year())
case .wide:
startDate.formatted(date: Date.FormatStyle.DateStyle.complete, time: Date.FormatStyle.TimeStyle.omitted)
case .short:
startDate.formatted(date: .numeric, time: .omitted)
}
startDate.formattedDate(displayStyle)
}
public func qualifiedFromGroupStage() -> Int {
@ -1466,7 +1472,7 @@ defer {
var _groupStages = [GroupStage]()
for index in 0..<groupStageCount {
let groupStage = GroupStage(tournament: id, index: index, size: teamsPerGroupStage, format: groupStageFormat)
let groupStage = GroupStage(tournament: id, index: index, size: teamsPerGroupStage, format: groupStageSmartMatchFormat())
_groupStages.append(groupStage)
}
@ -1547,6 +1553,9 @@ defer {
public func deleteGroupStages() {
self.tournamentStore?.groupStages.delete(contentOfs: allGroupStages())
if let gs = self.groupStageLoserBracket() {
self.tournamentStore?.rounds.delete(instance: gs)
}
}
public func refreshGroupStages(keepExistingMatches: Bool = false) {
@ -1711,7 +1720,6 @@ defer {
public func groupStageSmartMatchFormat() -> MatchFormat {
let format = tournamentLevel.federalFormatForGroupStage()
if tournamentLevel == .p25 { return .superTie }
if format.rank < groupStageMatchFormat.rank {
return format
} else {
@ -1849,7 +1857,6 @@ defer {
public func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat {
let format = tournamentLevel.federalFormatForBracketRound(roundIndex)
if tournamentLevel == .p25 { return .superTie }
if format.rank < matchFormat.rank {
return format
} else {
@ -2117,6 +2124,17 @@ defer {
}
}
public func removeRound(_ round: Round) async {
await MainActor.run {
let teams = round.seeds()
teams.forEach { team in
team.resetBracketPosition()
}
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams)
tournamentStore?.rounds.delete(instance: round)
}
}
public func addNewRound(_ roundIndex: Int) async {
await MainActor.run {
let round = Round(tournament: id, index: roundIndex, format: matchFormat)
@ -2302,7 +2320,11 @@ defer {
}
public func onlineTeams() -> [TeamRegistration] {
unsortedTeams().filter({ $0.hasRegisteredOnline() })
// guard let teamRegistrations = tournamentStore?.teamRegistrations else { return [] }
// return teamRegistrations.cached(key: "online") { collection in
// collection.filter { $0.hasRegisteredOnline() }
// }
return unsortedTeams().filter({ $0.hasRegisteredOnline() })
}
public func paidOnlineTeams() -> [TeamRegistration] {
@ -2337,7 +2359,7 @@ defer {
}
public func mailSubject() -> String {
let subject = [tournamentTitle(hideSenior: true), formattedDate(.short), clubName].compactMap({ $0 }).joined(separator: " | ")
let subject = [tournamentTitle(hideSenior: true), formattedDate(.short), customClubName ?? clubName].compactMap({ $0 }).joined(separator: " | ")
return subject
}
@ -2442,6 +2464,9 @@ defer {
}
public func addon(for playerRank: Int, manMax: Int, womanMax: Int) -> Int {
if tournamentCategory != .men {
return 0
}
switch playerRank {
case 0: return 0
case womanMax: return manMax - womanMax
@ -2481,6 +2506,16 @@ defer {
self.tournamentStore?.rounds.addOrUpdate(contentOfs: allRounds)
}
public func formatSummary() -> String {
var label = [String]()
if groupStageCount > 0 {
label.append("Poules " + groupStageMatchFormat.format)
}
label.append("Tableau " + matchFormat.format)
label.append("Classement " + loserBracketMatchFormat.format)
return label.joined(separator: ", ")
}
// MARK: -
func insertOnServer() throws {

@ -311,6 +311,18 @@ public extension Date {
let calendar = Calendar.current
return calendar.date(bySetting: .minute, value: 0, of: self)!.withoutSeconds()
}
func formattedDate(_ displayStyle: DisplayStyle = .wide) -> String {
switch displayStyle {
case .title:
self.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).year())
case .wide:
self.formatted(date: Date.FormatStyle.DateStyle.complete, time: Date.FormatStyle.TimeStyle.omitted)
case .short:
self.formatted(date: .numeric, time: .omitted)
}
}
}
public extension Date {

@ -213,16 +213,96 @@ public extension String {
// MARK: - FFT Source Importing
public extension String {
enum RegexStatic {
static let mobileNumber = /^(?:\+33|0033|0)[6-7](?:[ .-]?[0-9]{2}){4}$/
static let phoneNumber = /^(?:\+33|0033|0)[1-9](?:[ .-]?[0-9]{2}){4}$/
// Patterns for France only
static let phoneNumber = /^(\+33|0033|33|0)[1-9][0-9]{8}$/
static let phoneNumberWithExtra0 = /^33[0][1-9][0-9]{8}$/
static let mobileNumber = /^(\+33|0033|33|0)[6-7][0-9]{8}$/
static let mobileNumberWithExtra0 = /^33[0][6-7][0-9]{8}$/
}
func isMobileNumber() -> Bool {
firstMatch(of: RegexStatic.mobileNumber) != nil
private func cleanedNumberForValidation() -> String {
// Keep leading '+' if present, remove all other non-digit characters
var cleaned = self.trimmingCharacters(in: .whitespacesAndNewlines)
if cleaned.hasPrefix("+") {
// Preserve '+' at start, remove all other non-digit characters
let digitsOnly = cleaned.dropFirst().components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
cleaned = "+" + digitsOnly
} else {
// Remove all non-digit characters
cleaned = cleaned.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
}
return cleaned
}
func isPhoneNumber() -> Bool {
firstMatch(of: RegexStatic.phoneNumber) != nil
// MARK: - Phone Number Validation
/// Validate if the string is a mobile number for the specified locale.
/// - Parameter locale: The locale to validate against. Defaults to `.current`.
/// - Returns: True if the string matches the mobile number pattern for the locale.
func isMobileNumber(locale: Locale = .current) -> Bool {
// TODO: Support additional regions/locales in the future.
switch locale.region?.identifier {
case "FR", "fr", nil:
// French logic for now
let cleaned = cleanedNumberForValidation()
if cleaned.firstMatch(of: RegexStatic.mobileNumber) != nil {
return true
}
if cleaned.firstMatch(of: RegexStatic.mobileNumberWithExtra0) != nil {
return true
}
return false
default:
// For unsupported locales, fallback to checking if the string contains at least 8 digits
// This is a generic minimum length for most countries' phone numbers
let digitsOnly = self.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
return digitsOnly.count >= 8
}
}
/// Validate if the string is a phone number for the specified locale.
/// - Parameter locale: The locale to validate against. Defaults to `.current`.
/// - Returns: True if the string matches the phone number pattern for the locale.
func isPhoneNumber(locale: Locale = .current) -> Bool {
// TODO: Support additional regions/locales in the future.
switch locale.region?.identifier {
case "FR", "fr", nil:
// French logic for now
let cleaned = cleanedNumberForValidation()
if cleaned.firstMatch(of: RegexStatic.phoneNumber) != nil {
return true
}
if cleaned.firstMatch(of: RegexStatic.phoneNumberWithExtra0) != nil {
return true
}
return false
default:
// For unsupported locales, fallback to checking if the string contains at least 8 digits
// This is a generic minimum length for most countries' phone numbers
let digitsOnly = self.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
return digitsOnly.count >= 8
}
}
func normalize(_ phone: String) -> String {
var normalized = phone.trimmingCharacters(in: .whitespacesAndNewlines)
// Remove all non-digit characters
normalized = normalized.components(separatedBy: CharacterSet.decimalDigits.inverted).joined()
// Remove leading country code for France (33) if present
if normalized.hasPrefix("33") {
if normalized.dropFirst(2).hasPrefix("0") {
// Keep as is, don't strip the zero after 33
} else {
normalized = "0" + normalized.dropFirst(2)
}
} else if normalized.hasPrefix("0033") {
normalized = "0" + normalized.dropFirst(4)
}
return normalized
}
func isSamePhoneNumber(as other: String) -> Bool {
return normalize(self) == normalize(other)
}
func cleanSearchText() -> String {
@ -312,3 +392,4 @@ public extension String {
return self // Return the original string if parsing fails
}
}

@ -55,6 +55,89 @@ public enum ContactManagerError: LocalizedError {
}
}
public enum SummonType: Int, Identifiable {
case contact
case contactWithoutSignature
case summon
case summonWalkoutFollowUp
case summonErrorFollowUp
public func isRecall() -> Bool {
switch self {
case .contact, .contactWithoutSignature:
return false
case .summon:
return false
case .summonWalkoutFollowUp:
return true
case .summonErrorFollowUp:
return true
}
}
public func mainWord() -> String {
switch self {
case .contact:
return "Contacter"
case .contactWithoutSignature:
return "Contacter"
case .summon:
return "Convoquer"
case .summonWalkoutFollowUp:
return "Reconvoquer"
case .summonErrorFollowUp:
return "Reconvoquer"
}
}
public func caption() -> String? {
switch self {
case .contact:
return nil
case .contactWithoutSignature:
return "Sans texte par défaut"
case .summon:
return nil
case .summonWalkoutFollowUp:
return "Suite à un forfait"
case .summonErrorFollowUp:
return "Suite à une erreur"
}
}
public func shouldConfirm() -> Bool {
switch self {
case .contact, .contactWithoutSignature:
return false
case .summon:
return true
case .summonWalkoutFollowUp:
return true
case .summonErrorFollowUp:
return true
}
}
public func intro() -> String {
switch self {
case .contactWithoutSignature:
return ""
case .contact:
return "Vous êtes"
case .summon:
return "Vous êtes"
case .summonWalkoutFollowUp:
return "Suite à des forfaits, vous êtes finalement"
case .summonErrorFollowUp:
return "Suite à une erreur, vous êtes finalement"
}
}
public var id: Int {
self.rawValue
}
}
public 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?)
@ -76,7 +159,7 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c
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 ?? ""
let clubName = tournament?.customClubName ?? tournament?.clubName ?? ""
var text = tournamentCustomMessage
let date = startDate ?? tournament?.startDate ?? Date()
@ -97,8 +180,10 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c
return text
}
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, reSummon: Bool = false) -> String {
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, summonType: SummonType = .summon) -> String {
if summonType == .contactWithoutSignature {
return ""
}
let useFullCustomMessage = DataStore.shared.user.summonsUseFullCustomMessage
if useFullCustomMessage {
@ -107,7 +192,7 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c
let date = startDate ?? tournament?.startDate ?? Date()
let clubName = tournament?.clubName ?? ""
let clubName = tournament?.customClubName ?? tournament?.clubName ?? ""
let message = DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage
let signature = DataStore.shared.user.getSummonsMessageSignature() ?? DataStore.shared.user.defaultSignature(tournament)
@ -129,7 +214,7 @@ Il est conseillé de vous présenter 10 minutes avant de jouer.\n\nMerci de me c
[entryFeeMessage, message, linkMessage].compacted().map { $0.trimmedMultiline }.joined(separator: "\n\n")
}
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes"
let intro = summonType.intro()
if let tournament {
return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.title, hideSenior: true)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)"

@ -35,3 +35,23 @@ public enum ExportFormat: Int, Identifiable, CaseIterable {
return Array(repeating: "\n", count: count).joined()
}
}
public enum ExportType: Int, Identifiable, CaseIterable {
public var id: Int { self.rawValue }
case sharing
case payment
public func localizedString() -> String {
switch self {
case .sharing:
return "inscriptions"
case .payment:
return "pointage"
}
}
public func newLineSeparator(_ count: Int = 1) -> String {
return Array(repeating: "\n", count: count).joined()
}
}

@ -349,7 +349,9 @@ public enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifi
return 4
} else {
switch level {
case .p25, .p100, .p250:
case .p25:
return 4
case .p100, .p250:
if category == .women {
return 4
}
@ -710,7 +712,7 @@ public enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable
case 13...16:
return [175,150,138,113,100,88,75,63,53,45,38,25,13,8,3]
case 17...20:
return [188,163,150,138,123,113,100,88,75,63,58,50,45,38,30,25,13,8,3]
return [188,163,150,138,125,113,100,88,75,63,58,50,45,38,30,25,13,8,3]
case 21...24:
return [188,175,163,150,138,125,118,108,100,93,83,75,70,63,58,50,45,38,30,25,13,8,3]
case 25...28:
@ -1199,6 +1201,7 @@ public enum TeamPosition: Int, Identifiable, Hashable, Codable, CaseIterable {
public enum SetFormat: Int, Hashable, Codable {
case nine
case four
case three
case six
case superTieBreak
case megaTieBreak
@ -1225,6 +1228,10 @@ public enum SetFormat: Int, Hashable, Codable {
if teamOne == 5 || teamTwo == 5 {
return true
}
case .three:
if teamOne == 4 || teamTwo == 4 {
return true
}
case .six:
if teamOne == 7 || teamTwo == 7 {
return true
@ -1243,6 +1250,8 @@ public enum SetFormat: Int, Hashable, Codable {
return 8
case .four:
return 4
case .three:
return 3
case .six:
return 6
case .superTieBreak, .megaTieBreak:
@ -1260,6 +1269,10 @@ public enum SetFormat: Int, Hashable, Codable {
if teamOneScore == 4 {
return []
}
case .three:
if teamOneScore == 3 {
return []
}
case .six:
if teamOneScore == 6 {
return []
@ -1282,6 +1295,8 @@ public enum SetFormat: Int, Hashable, Codable {
return [9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
case .four:
return [5, 4, 3, 2, 1, 0]
case .three:
return [4, 3, 2, 1, 0]
case .six:
return [7, 6, 5, 4, 3, 2, 1, 0]
case .superTieBreak:
@ -1297,6 +1312,8 @@ public enum SetFormat: Int, Hashable, Codable {
return 9
case .four:
return 4
case .three:
return 4
case .six:
return 6
case .superTieBreak:
@ -1364,15 +1381,15 @@ public enum MatchFormat: Int, Hashable, Codable, CaseIterable, Identifiable {
return 2
case .nineGames, .nineGamesDecisivePoint:
return 3
case .superTie:
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
return 4
case .megaTie:
case .superTie:
return 5
case .twoSetsOfSuperTie:
case .megaTie:
return 6
case .singleSet, .singleSetDecisivePoint:
case .twoSetsOfSuperTie:
return 7
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
case .singleSet, .singleSetDecisivePoint:
return 8
}
}
@ -1395,15 +1412,15 @@ public enum MatchFormat: Int, Hashable, Codable, CaseIterable, Identifiable {
return 3
case .nineGamesDecisivePoint:
return 3
case .superTie:
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
return 4
case .megaTie:
case .superTie:
return 5
case .twoSetsOfSuperTie:
case .megaTie:
return 6
case .singleSet, .singleSetDecisivePoint:
case .twoSetsOfSuperTie:
return 7
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
case .singleSet, .singleSetDecisivePoint:
return 8
}
}
@ -1641,7 +1658,7 @@ public enum MatchFormat: Int, Hashable, Codable, CaseIterable, Identifiable {
case .megaTie:
return "supertie de 15 points"
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
return "1 set de 4 jeux, tiebreak à 4/4"
return "1 set de 4 jeux, tiebreak à 3/3"
}
}
@ -1670,8 +1687,10 @@ public enum MatchFormat: Int, Hashable, Codable, CaseIterable, Identifiable {
switch self {
case .twoSets, .twoSetsSuperTie, .twoSetsDecisivePoint, .twoSetsDecisivePointSuperTie, .singleSet, .singleSetDecisivePoint:
return .six
case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint:
return .four
case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint:
return .three
case .nineGames, .nineGamesDecisivePoint:
return .nine
case .superTie, .twoSetsOfSuperTie:
@ -1850,6 +1869,16 @@ public enum RoundRule {
}
}
public static func cumulatedNumberOfMatches(forTeams teams: Int) -> Int {
var i = teams / 2
var loserTeams = teams / 2
while loserTeams > 1 {
i += Self.cumulatedNumberOfMatches(forTeams: loserTeams)
loserTeams = loserTeams / 2
}
return i
}
public static func teamsInFirstRound(forTeams teams: Int) -> Int {
Int(pow(2.0, ceil(log2(Double(teams)))))
}
@ -1880,12 +1909,16 @@ public enum RoundRule {
}
public static func numberOfMatches(forRoundIndex roundIndex: Int) -> Int {
Int(pow(2.0, Double(roundIndex)))
(1 << roundIndex)
}
public static func baseIndex(forRoundIndex roundIndex: Int) -> Int {
numberOfMatches(forRoundIndex: roundIndex) - 1
}
static func matchIndexWithinRound(fromMatchIndex matchIndex: Int) -> Int {
let roundIndex = roundIndex(fromMatchIndex: matchIndex)
let matchIndexWithinRound = matchIndex - (Int(pow(2.0, Double(roundIndex))) - 1)
let matchIndexWithinRound = matchIndex - baseIndex(forRoundIndex: roundIndex)
return matchIndexWithinRound
}
@ -1909,7 +1942,7 @@ public enum RoundRule {
}
return "Quart de finale"
default:
return "\(Int(pow(2.0, Double(roundIndex))))ème"
return "\((1 << roundIndex))ème"
}
}
}

Loading…
Cancel
Save