diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4854eab Binary files /dev/null and b/.DS_Store differ diff --git a/PadelClubData.xcodeproj/project.pbxproj b/PadelClubData.xcodeproj/project.pbxproj new file mode 100644 index 0000000..efab593 --- /dev/null +++ b/PadelClubData.xcodeproj/project.pbxproj @@ -0,0 +1,544 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + C4AD86CA2DAEB37900022F97 /* PadelClubData.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4AD86BF2DAEB37900022F97 /* PadelClubData.framework */; }; + C4AD86E22DAEB39100022F97 /* LeStorage.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4AD86E12DAEB39100022F97 /* LeStorage.framework */; }; + C4AD86E32DAEB39100022F97 /* LeStorage.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C4AD86E12DAEB39100022F97 /* LeStorage.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + C4B685A02DAF99CE0031750D /* Algorithms in Frameworks */ = {isa = PBXBuildFile; productRef = C4B6859F2DAF99CE0031750D /* Algorithms */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + C4AD86CB2DAEB37900022F97 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = C4AD86B62DAEB37900022F97 /* Project object */; + proxyType = 1; + remoteGlobalIDString = C4AD86BE2DAEB37900022F97; + remoteInfo = PadelClubData; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + C4AD86E42DAEB39100022F97 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + C4AD86E32DAEB39100022F97 /* LeStorage.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + C4AD86BF2DAEB37900022F97 /* PadelClubData.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = PadelClubData.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C4AD86C92DAEB37900022F97 /* PadelClubDataTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = PadelClubDataTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + C4AD86E12DAEB39100022F97 /* LeStorage.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = LeStorage.framework; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + C4AD86D12DAEB37900022F97 /* Exceptions for "PadelClubData" folder in "PadelClubData" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + publicHeaders = ( + PadelClubData.h, + ); + target = C4AD86BE2DAEB37900022F97 /* PadelClubData */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + C4AD86C12DAEB37900022F97 /* PadelClubData */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + C4AD86D12DAEB37900022F97 /* Exceptions for "PadelClubData" folder in "PadelClubData" target */, + ); + path = PadelClubData; + sourceTree = ""; + }; + C4AD86CD2DAEB37900022F97 /* PadelClubDataTests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = PadelClubDataTests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + C4AD86BC2DAEB37900022F97 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C4B685A02DAF99CE0031750D /* Algorithms in Frameworks */, + C4AD86E22DAEB39100022F97 /* LeStorage.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C4AD86C62DAEB37900022F97 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + C4AD86CA2DAEB37900022F97 /* PadelClubData.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + C4AD86B52DAEB37900022F97 = { + isa = PBXGroup; + children = ( + C4AD86C12DAEB37900022F97 /* PadelClubData */, + C4AD86CD2DAEB37900022F97 /* PadelClubDataTests */, + C4AD86E02DAEB39100022F97 /* Frameworks */, + C4AD86C02DAEB37900022F97 /* Products */, + ); + sourceTree = ""; + }; + C4AD86C02DAEB37900022F97 /* Products */ = { + isa = PBXGroup; + children = ( + C4AD86BF2DAEB37900022F97 /* PadelClubData.framework */, + C4AD86C92DAEB37900022F97 /* PadelClubDataTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + C4AD86E02DAEB39100022F97 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C4AD86E12DAEB39100022F97 /* LeStorage.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXHeadersBuildPhase section */ + C4AD86BA2DAEB37900022F97 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXHeadersBuildPhase section */ + +/* Begin PBXNativeTarget section */ + C4AD86BE2DAEB37900022F97 /* PadelClubData */ = { + isa = PBXNativeTarget; + buildConfigurationList = C4AD86D22DAEB37900022F97 /* Build configuration list for PBXNativeTarget "PadelClubData" */; + buildPhases = ( + C4AD86BA2DAEB37900022F97 /* Headers */, + C4AD86BB2DAEB37900022F97 /* Sources */, + C4AD86BC2DAEB37900022F97 /* Frameworks */, + C4AD86BD2DAEB37900022F97 /* Resources */, + C4AD86E42DAEB39100022F97 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + C4AD86C12DAEB37900022F97 /* PadelClubData */, + ); + name = PadelClubData; + packageProductDependencies = ( + C4B6859F2DAF99CE0031750D /* Algorithms */, + ); + productName = PadelClubData; + productReference = C4AD86BF2DAEB37900022F97 /* PadelClubData.framework */; + productType = "com.apple.product-type.framework"; + }; + C4AD86C82DAEB37900022F97 /* PadelClubDataTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = C4AD86D72DAEB37900022F97 /* Build configuration list for PBXNativeTarget "PadelClubDataTests" */; + buildPhases = ( + C4AD86C52DAEB37900022F97 /* Sources */, + C4AD86C62DAEB37900022F97 /* Frameworks */, + C4AD86C72DAEB37900022F97 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + C4AD86CC2DAEB37900022F97 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + C4AD86CD2DAEB37900022F97 /* PadelClubDataTests */, + ); + name = PadelClubDataTests; + packageProductDependencies = ( + ); + productName = PadelClubDataTests; + productReference = C4AD86C92DAEB37900022F97 /* PadelClubDataTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + C4AD86B62DAEB37900022F97 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1620; + LastUpgradeCheck = 1620; + TargetAttributes = { + C4AD86BE2DAEB37900022F97 = { + CreatedOnToolsVersion = 16.2; + }; + C4AD86C82DAEB37900022F97 = { + CreatedOnToolsVersion = 16.2; + }; + }; + }; + buildConfigurationList = C4AD86B92DAEB37900022F97 /* Build configuration list for PBXProject "PadelClubData" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = C4AD86B52DAEB37900022F97; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + C4B6859E2DAF99CE0031750D /* XCRemoteSwiftPackageReference "swift-algorithms" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = C4AD86C02DAEB37900022F97 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + C4AD86BE2DAEB37900022F97 /* PadelClubData */, + C4AD86C82DAEB37900022F97 /* PadelClubDataTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + C4AD86BD2DAEB37900022F97 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C4AD86C72DAEB37900022F97 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + C4AD86BB2DAEB37900022F97 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + C4AD86C52DAEB37900022F97 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + C4AD86CC2DAEB37900022F97 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = C4AD86BE2DAEB37900022F97 /* PadelClubData */; + targetProxy = C4AD86CB2DAEB37900022F97 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + C4AD86D32DAEB37900022F97 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 526E96RFNP; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.PadelClubData; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C4AD86D42DAEB37900022F97 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUILD_LIBRARY_FOR_DISTRIBUTION = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = 526E96RFNP; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + ENABLE_MODULE_VERIFIER = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + IPHONEOS_DEPLOYMENT_TARGET = 17.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + MODULE_VERIFIER_SUPPORTED_LANGUAGES = "objective-c objective-c++"; + MODULE_VERIFIER_SUPPORTED_LANGUAGE_STANDARDS = "gnu17 gnu++20"; + PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.PadelClubData; + PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_INSTALL_OBJC_HEADER = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + C4AD86D52DAEB37900022F97 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Debug; + }; + C4AD86D62DAEB37900022F97 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.2; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + VERSIONING_SYSTEM = "apple-generic"; + VERSION_INFO_PREFIX = ""; + }; + name = Release; + }; + C4AD86D82DAEB37900022F97 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 526E96RFNP; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-DTEST"; + PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.PadelClubDataTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + C4AD86D92DAEB37900022F97 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 526E96RFNP; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + OTHER_SWIFT_FLAGS = "-DTEST"; + PRODUCT_BUNDLE_IDENTIFIER = com.staxriver.PadelClubDataTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + C4AD86B92DAEB37900022F97 /* Build configuration list for PBXProject "PadelClubData" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4AD86D52DAEB37900022F97 /* Debug */, + C4AD86D62DAEB37900022F97 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C4AD86D22DAEB37900022F97 /* Build configuration list for PBXNativeTarget "PadelClubData" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4AD86D32DAEB37900022F97 /* Debug */, + C4AD86D42DAEB37900022F97 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + C4AD86D72DAEB37900022F97 /* Build configuration list for PBXNativeTarget "PadelClubDataTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + C4AD86D82DAEB37900022F97 /* Debug */, + C4AD86D92DAEB37900022F97 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + C4B6859E2DAF99CE0031750D /* XCRemoteSwiftPackageReference "swift-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + C4B6859F2DAF99CE0031750D /* Algorithms */ = { + isa = XCSwiftPackageProductDependency; + package = C4B6859E2DAF99CE0031750D /* XCRemoteSwiftPackageReference "swift-algorithms" */; + productName = Algorithms; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = C4AD86B62DAEB37900022F97 /* Project object */; +} diff --git a/PadelClubData.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/PadelClubData.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/PadelClubData.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/PadelClubData/.DS_Store b/PadelClubData/.DS_Store new file mode 100644 index 0000000..ca047a2 Binary files /dev/null and b/PadelClubData/.DS_Store differ diff --git a/PadelClubData/Business/DayPeriod.swift b/PadelClubData/Business/DayPeriod.swift new file mode 100644 index 0000000..b418ff7 --- /dev/null +++ b/PadelClubData/Business/DayPeriod.swift @@ -0,0 +1,27 @@ +// +// DayPeriod.swift +// PadelClubData +// +// Created by Laurent Morvillier on 15/04/2025. +// + +import Foundation + +public enum DayPeriod: Int, CaseIterable, Identifiable, Codable { + public var id: Int { self.rawValue } + + case all + case weekend + case week + + public func localizedDayPeriodLabel() -> String { + switch self { + case .all: + return "n'importe" + case .week: + return "la semaine" + case .weekend: + return "le week-end" + } + } +} diff --git a/PadelClubData/Business/DisplayContext.swift b/PadelClubData/Business/DisplayContext.swift new file mode 100644 index 0000000..becb38a --- /dev/null +++ b/PadelClubData/Business/DisplayContext.swift @@ -0,0 +1,64 @@ +// +// DisplayContext.swift +// PadelClub +// +// Created by Razmig Sarkissian on 20/03/2024. +// + +import Foundation +import UIKit + +public enum DisplayContext { + case addition + case edition + case lockedForEditing + case selection +} + +public enum DisplayStyle { + case title + case wide + case short +} + +public enum SummoningDisplayContext { + case footer + case menu +} + +public struct DeviceHelper { + public static func isBigScreen() -> Bool { + switch UIDevice.current.userInterfaceIdiom { + case .pad: // iPads + return true + case .phone: // iPhones (you can add more cases here for large vs small phones) + if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc. + return true // large phones + } else { + return false // smaller phones + } + default: + return false // Other devices (Apple Watch, TV, etc.) + } + + } + + public static func maxCharacter() -> Int { + switch UIDevice.current.userInterfaceIdiom { + case .pad: // iPads + return 30 + case .phone: // iPhones (you can add more cases here for large vs small phones) + if UIScreen.main.bounds.size.width > 375 { // iPhone X, 11, 12, 13 Pro Max etc. + return 15 // large phones + } else { + return 9 // smaller phones + } + default: + return 9 // Other devices (Apple Watch, TV, etc.) + } + } + + public static func charLength() -> Int { + isBigScreen() ? 0 : 15 + } +} diff --git a/PadelClubData/Business/ExportFormat.swift b/PadelClubData/Business/ExportFormat.swift new file mode 100644 index 0000000..e60460a --- /dev/null +++ b/PadelClubData/Business/ExportFormat.swift @@ -0,0 +1,37 @@ +// +// ExportFormat.swift +// PadelClub +// +// Created by Razmig Sarkissian on 19/07/2024. +// + +import Foundation + +public enum ExportFormat: Int, Identifiable, CaseIterable { + public 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() + } +} diff --git a/PadelClubData/Business/MatchDescriptor.swift b/PadelClubData/Business/MatchDescriptor.swift new file mode 100644 index 0000000..6a48240 --- /dev/null +++ b/PadelClubData/Business/MatchDescriptor.swift @@ -0,0 +1,156 @@ +// +// swift +// PadelClub +// +// Created by Razmig Sarkissian on 02/04/2024. +// + +import Foundation +import SwiftUI +import Combine + +public class MatchDescriptor: ObservableObject { + + @Published public var matchFormat: MatchFormat + @Published public var setDescriptors: [SetDescriptor] + public var court: Int = 1 + public var title: String = "Titre du match" + public var teamLabelOne: String = "" + public var teamLabelTwo: String = "" + public var startDate: Date = Date() + public var match: Match? + public let colorTeamOne: Color = .teal + public let colorTeamTwo: Color = .indigo + + public 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) + }) + } + } + + public var teamOneSetupIsActive: Bool { + if hasEnded && showSetInputView == false && showTieBreakInputView == false { + return false + } + + guard let setDescriptor = setDescriptors.last else { + return false + } + if setDescriptor.valueTeamOne == nil { + return true + } else if setDescriptor.valueTeamTwo == nil { + return false + } else if setDescriptor.tieBreakValueTeamOne == nil, setDescriptor.shouldTieBreak { + return true + } else if setDescriptor.tieBreakValueTeamTwo == nil, setDescriptor.shouldTieBreak { + return false + } + + return false + } + + public var teamTwoSetupIsActive: Bool { + if hasEnded && showSetInputView == false && showTieBreakInputView == false { + return false + } + guard let setDescriptor = setDescriptors.last else { + return false + } + + if setDescriptor.valueTeamOne == nil { + return false + } else if setDescriptor.valueTeamTwo == nil { + return true + } else if setDescriptor.tieBreakValueTeamOne == nil, setDescriptor.shouldTieBreak { + return false + } else if setDescriptor.tieBreakValueTeamTwo == nil, setDescriptor.shouldTieBreak { + return true + } + + return true + } + + public var showSetInputView: Bool { + return setDescriptors.anySatisfy({ $0.showSetInputView }) + } + + public var showTieBreakInputView: Bool { + return setDescriptors.anySatisfy({ $0.showTieBreakInputView }) + } + + public var teamOneScores: [String] { + setDescriptors.compactMap { $0.getValue(teamPosition: .one) } + } + + public var teamTwoScores: [String] { + setDescriptors.compactMap { $0.getValue(teamPosition: .two) } + } + + public var scoreTeamOne: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .one }.count } + public var scoreTeamTwo: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .two }.count } + + public var hasEnded: Bool { + return matchFormat.hasEnded(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) + } + + public func addNewSet() { + if hasEnded == false { + setDescriptors.append(SetDescriptor(setFormat: matchFormat.newSetFormat(setCount: setDescriptors.count))) + } + } + + public var winner: TeamPosition { + matchFormat.winner(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) + } + + public 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 +} diff --git a/PadelClubData/Business/MatchSpot.swift b/PadelClubData/Business/MatchSpot.swift new file mode 100644 index 0000000..c8f2dbe --- /dev/null +++ b/PadelClubData/Business/MatchSpot.swift @@ -0,0 +1,29 @@ +// +// MatchSpot.swift +// PadelClub +// +// Created by razmig on 22/03/2025. +// + + +public struct MatchSpot: SpinDrawable { + public let match: Match + public let teamPosition: TeamPosition + + public func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] { + [match.roundTitle(), matchTitle(displayStyle: displayStyle)].compactMap { $0 } + } + + public func matchTitle(displayStyle: DisplayStyle) -> String { + [match.matchTitle(displayStyle), teamPositionLabel()].joined(separator: " - ") + } + + public func teamPositionLabel() -> String { + switch teamPosition { + case .one: + return "haut" + case .two: + return "bas" + } + } +} diff --git a/PadelClubData/Business/OnlineRegistrationStatus.swift b/PadelClubData/Business/OnlineRegistrationStatus.swift new file mode 100644 index 0000000..19f3bca --- /dev/null +++ b/PadelClubData/Business/OnlineRegistrationStatus.swift @@ -0,0 +1,61 @@ +// +// OnlineRegistrationStatus.swift +// PadelClubData +// +// Created by Laurent Morvillier on 15/04/2025. +// + +import Foundation + +public enum OnlineRegistrationStatus: Int { + case open = 1 + case notEnabled = 2 + case notStarted = 3 + case ended = 4 + case waitingListPossible = 5 + case waitingListFull = 6 + case inProgress = 7 + case endedWithResults = 8 + + public var displayName: String { + switch self { + case .open: + return "Open" + case .notEnabled: + return "Not Enabled" + case .notStarted: + return "Not Started" + case .ended: + return "Ended" + case .waitingListPossible: + return "Waiting List Possible" + case .waitingListFull: + return "Waiting List Full" + case .inProgress: + return "In Progress" + case .endedWithResults: + return "Ended with Results" + } + } + + public func statusLocalized() -> String { + switch self { + case .open: + return "Inscription ouverte" + case .notEnabled: + return "Inscription désactivée" + case .notStarted: + return "Inscription pas encore ouverte" + case .ended: + return "Inscription terminée" + case .waitingListPossible: + return "Liste d'attente disponible" + case .waitingListFull: + return "Liste d'attente complète" + case .inProgress: + return "Tournoi en cours" + case .endedWithResults: + return "Tournoi terminé" + } + } +} diff --git a/PadelClubData/Business/PadelRule.swift b/PadelClubData/Business/PadelRule.swift new file mode 100644 index 0000000..7ee1bd1 --- /dev/null +++ b/PadelClubData/Business/PadelRule.swift @@ -0,0 +1,2101 @@ +// +// PadelRule.swift +// Padel Tournament +// +// Created by razmig on 27/02/2023. +// + +import Foundation +import LeStorage + +enum RankSource: Hashable { + case national + case ligue + case club(assimilation: Bool) + + func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .national: + return "Classement National" + case .ligue: + return "Classement Ligue" + case .club: + return "Classement Club" + } + } +} + +public protocol TournamentBuildHolder: Identifiable { + var id: String { get } + var category: TournamentCategory { get } + var level: TournamentLevel { get } + var age: FederalTournamentAge { get } + func buildHolderTitle(_ displayStyle: DisplayStyle) -> String +} + +public struct TournamentBuild: TournamentBuildHolder, Hashable, Codable, Identifiable { + + public var uniqueId: String = Store.randomId() + public var id: String { uniqueId } + public let category: TournamentCategory + public let level: TournamentLevel + public let age: FederalTournamentAge + + public init(category: TournamentCategory, level: TournamentLevel, age: FederalTournamentAge) { + self.category = category + self.level = level + self.age = age + } + +// var japIdentifier: Int? = nil +// var japFirstName: String? = nil +// var japLastName: String? = nil + + public func buildHolderTitle(_ displayStyle: DisplayStyle) -> String { + computedLabel(displayStyle) + } + + var identifier: String { + level.localizedLevelLabel()+":"+category.localizedLabel()+":"+age.localizedFederalAgeLabel() + } + + func computedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if age == .senior { return localizedLabel(displayStyle) } + return localizedLabel(displayStyle) + " " + localizedAge(displayStyle) + } + + func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle) + } + + func localizedTitle(_ displayStyle: DisplayStyle = .wide) -> String { + level.localizedLevelLabel(displayStyle) + " " + category.localizedLabel(displayStyle) + } + + func localizedAge(_ displayStyle: DisplayStyle = .wide) -> String { + age.localizedFederalAgeLabel(displayStyle) + } +} + +public extension TournamentBuild { + + init?(category: String, level: String, age: FederalTournamentAge = .senior) { + guard let levelFound = TournamentLevel.allCases.first(where: { $0.localizedLevelLabel() == level }) else { return nil } + + var c = category + if c.hasPrefix("ME") { + c = "H" + } + + if c.hasPrefix("F") { + c = "D" + } + + guard let categoryFound = TournamentCategory.allCases.first(where: { c.canonicalVersion.hasPrefix($0.buildLabel.canonicalVersion) }) else { return nil } + self.level = levelFound + self.category = categoryFound + self.age = age + } +} + +public enum FederalTournamentType: String, Hashable, Codable, CaseIterable, Identifiable { + case tournoi = "P" + case championnatParEquipe = "S" + case championnatParPaire = "L" + + public var id: String { self.rawValue } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .tournoi: + return "Tournois" + case .championnatParEquipe: + return "Championnats par équipes" + case .championnatParPaire: + return "Championnats par paires" + } + } +} + +public enum TournamentDifficulty { + + case rankS + case rankA + case rankB + case rankC + + init?(gameDifference: Double) { + switch gameDifference { + case ..<3: + self = .rankS + case ..<5: + self = .rankA + case ..<7: + self = .rankB + case 7...: + self = .rankC + default: + return nil + } + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .rankS: + return "S" + case .rankA: + return "A" + case .rankB: + return "B" + case .rankC: + return "C" + } + } + + public var backgroundColor: String { + switch self { + case .rankS: + return "#d4af37" + case .rankA: + return "#c0c0c0" + case .rankB: + return "#cd7f32" + case .rankC: + return "#DCC2E0" + } + } +} + +public enum FederalTournamentAge: Int, Hashable, Codable, CaseIterable, Identifiable { + case unlisted = 0 + case a11_12 = 120 + case a13_14 = 140 + case a15_16 = 160 + case a17_18 = 180 + case senior = 200 + case a45 = 450 + case a55 = 550 + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public func computedBirthYear() -> (Int?, Int?) { + let year = Calendar.current.getSportAge() + switch self { + case .unlisted: + return (nil, nil) + case .a11_12: + return (year - 12, year - 11) + case .a13_14: + return (year - 14, year - 13) + case .a15_16: + return (year - 16, year - 15) + case .a17_18: + return (year - 18, year - 17) + case .senior: + return (nil, year - 19) + case .a45: + return (nil, year - 45) + case .a55: + return (nil, year - 55) + } + } + + public var importingRawValue: String { + switch self { + case .unlisted: + return "Animation" + case .a11_12: + return "11/12 ans" + case .a13_14: + return "13/14 ans" + case .a15_16: + return "15/16 ans" + case .a17_18: + return "17/18 ans" + case .senior: + return "Senior" + case .a45: + return "45 ans" + case .a55: + return "55 ans" + } + } + + public static func mostRecent(inTournaments tournaments: [Tournament]) -> Self { + return tournaments.first?.federalTournamentAge ?? .senior + } + + public static func mostUsed(inTournaments tournaments: [Tournament]) -> Self { + let countedSet = NSCountedSet(array: tournaments.map { $0.federalTournamentAge }) + let mostFrequent = countedSet.max { countedSet.count(for: $0) < countedSet.count(for: $1) } + if mostFrequent != nil { + return mostFrequent as! FederalTournamentAge + } else { + return mostRecent(inTournaments: tournaments) + } + } + + public var id: Int { self.rawValue } + + public var order: Int { + switch self { + case .unlisted: + return 7 + case .a11_12: + return 6 + case .a13_14: + return 5 + case .a15_16: + return 4 + case .a17_18: + return 3 + case .senior: + return 0 + case .a45: + return 1 + case .a55: + return 2 + } + } + + public func localizedFederalAgeLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .unlisted: + return displayStyle == .title ? "Aucune" : "" + case .a11_12: + return "11/12 ans" + case .a13_14: + return "13/14 ans" + case .a15_16: + return "15/16 ans" + case .a17_18: + return "17/18 ans" + case .senior: + return displayStyle == .short ? "" : "Senior" + case .a45: + return "+45 ans" + case .a55: + return "+55 ans" + } + } + + public var tournamentDescriptionLabel: String { + return localizedFederalAgeLabel() + } + + public func isAgeValid(age: Int?) -> Bool { + guard let age else { return true } + switch self { + case .unlisted: + return true + case .a11_12: + return age < 13 + case .a13_14: + return age < 15 + case .a15_16: + return age < 17 + case .a17_18: + return age < 19 + case .senior: + return age >= 11 + case .a45: + return age >= 45 + case .a55: + return age >= 55 + } + } +} + +public enum TournamentLevel: Int, Hashable, Codable, CaseIterable, Identifiable { + case unlisted = 0 + case p25 = 25 + case p100 = 100 + case p250 = 250 + case p500 = 500 + case p1000 = 1000 + case p1500 = 1500 + case p2000 = 2000 + case championship = 1 + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public static var assimilationAllCases: [TournamentLevel] { + return [.p25, .p100, .p250, .p500, .p1000, .p1500, .p2000] + } + + public var entryFee: Double? { + switch self { + case .unlisted, .championship: + return nil + case .p25: + return 15 + default: + return 20 + } + } + + public func isAnimation() -> Bool { + switch self { + case .unlisted: + return true + case .championship: + return false + default: + return false + } + } + + public func searchRawValue() -> String { + String(describing: self) + } + + public func pointsRange(first: Int, last: Int, teamsCount: Int) -> String { + let range = [points(for: first - 1, count: teamsCount), + points(for: last - 1, count: teamsCount)] + return range.map { $0.formatted(.number.sign(strategy: .always())) }.joined(separator: " / ") + " pts" + } + + public func hideWeight() -> Bool { + switch self { + case .unlisted: + return true + default: + return false + } + } + + public func shouldShareTeams() -> Bool { + switch self { + case .p500, .p1000, .p1500, .p2000: + return true + default: + return false + } + } + + public static func mostRecent(inTournaments tournaments: [Tournament]) -> Self { + return tournaments.first?.tournamentLevel ?? .p100 + } + + public static func mostUsed(inTournaments tournaments: [Tournament]) -> Self { + let countedSet = NSCountedSet(array: tournaments.map { $0.tournamentLevel }) + let mostFrequent = countedSet.max { countedSet.count(for: $0) < countedSet.count(for: $1) } + if mostFrequent != nil { + return mostFrequent as! TournamentLevel + } else { + return mostRecent(inTournaments: tournaments) + } + } + + public var id: Int { self.rawValue } + + public func wildcardArePossible() -> Bool { + switch self { + case .p500, .p1000, .p1500, .p2000: + return true + default: + return false + } + } + + public func minimumPlayerRank(category: TournamentCategory, ageCategory: FederalTournamentAge) -> Int { + switch self { + case .p25: + switch ageCategory { + case .senior, .a45, .a55: + return category == .men ? 20000 : 1000 + default: + return 0 + } + case .p100: + switch ageCategory { + case .senior, .a45, .a55: + return category == .men ? 2000 : 300 + default: + return 0 + } + + case .p250: + switch ageCategory { + case .senior, .a45, .a55: + if category == .mix { return 0 } + return category == .men ? 500 : 100 + default: + return 0 + } + + default: + return 0 + } + } + + public func federalFormatForGroupStage() -> MatchFormat { + federalFormatForBracketRound(5) + } + + public func federalFormatForBracketRound(_ roundIndex: Int) -> MatchFormat { + switch self { + case .p25: + return .superTie + case .p100: + return .nineGamesDecisivePoint + case .p250: + if roundIndex == 0 { //finale + return .twoSetsDecisivePointSuperTie + } else { + return .nineGamesDecisivePoint + } + + case .p500: + if roundIndex == 0 { //finale + return .twoSetsDecisivePointSuperTie + } else if roundIndex == 1 { //demi-finale + return .twoSetsDecisivePointSuperTie + } else { + return .nineGamesDecisivePoint + } + case .p1000: + if roundIndex <= 3 { //demi / finale / quart / 8eme + return .twoSetsDecisivePoint + } else { + return .twoSetsDecisivePointSuperTie + } + case .p1500, .p2000: + if roundIndex <= 3 { //demi / finale / quart / 8eme + return .twoSetsDecisivePoint + } else { + return .twoSetsSuperTie + } + default: + return .superTie + } + } + + public func decisivePointRequired(ageCategory: FederalTournamentAge) -> Bool { + switch ageCategory { + case .a11_12, .a13_14, .a15_16, .a17_18: + return true + default: + return false + } + } + + public func federalFormatForLoserBracketRound(_ roundIndex: Int) -> MatchFormat { + switch self { + case .p25: + return .superTie + case .p100, .p250, .p500: + return .nineGamesDecisivePoint + case .p1000: + return .nineGamesDecisivePoint + case .p1500, .p2000: + return .twoSetsSuperTie + default: + return .nineGamesDecisivePoint + } + } + + + public var defaultTeamSortingType: TeamSortingType { + switch self { + case .p25, .p100, .p250: + return .inscriptionDate + default: + return .rank + } + } + + public var order: Int { + switch self { + case .unlisted: + return 7 + case .p25: + return 6 + case .p100: + return 5 + case .p250: + return 4 + case .p500: + return 3 + case .p1000: + return 2 + case .p1500: + return 1 + case .p2000: + return 0 + case .championship: + return 8 + } + } + + public func localizedLevelLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if self == .unlisted { + if DeviceHelper.isBigScreen() { + return "Animation" + } else { + return displayStyle == .title ? "Animation" : "Anim." + } + } + if self == .championship { + if DeviceHelper.isBigScreen() { + return "Championnat" + } else { + return displayStyle == .title ? "Championnat" : "CHPT" + } + } + + return String(describing: self).capitalized + } + + public var coachingIsAuthorized: Bool { + switch self { + case .p500, .p1000, .p1500, .p2000: + return true + default: + return false + } + } + + public func points(for rank: Int, count: Int) -> Int { + if self == .unlisted { return 0 } + let points = points(for: count) + if rank >= points.count { + return points.last! + } else if rank == 0 { + return Int(self.rawValue) + } else { + return points[rank-1] + } + } + + public func allPoints(for count: Int) -> [Int] { + [Int(self.rawValue)] + points(for: count) + } + + public func points(for count: Int) -> [Int] { + switch self { + case .unlisted, .championship: + return [] + case .p25: + switch count { + case 9...12: + return [17, 15, 13, 11, 9, 7, 5, 4, 3, 2, 1] + case 13...16: + return [18,16,15,14,13,12,11,10,9,7,5,4,3,2, 1] + case 17...20: + return [20,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2, 1] + case 21...24: + return [20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2, 1] + case 25...28: + return [21,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2, 1] + case _ where count > 28: + return [23,21,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2, 1] + default: + return [15, 12, 9, 6, 4, 2, 1] + } + case .p100: + switch count { + case 9...12: + return [65,55, 50, 35, 25, 20, 15, 10, 5, 3, 1] + case 13...16: + return [70, 60, 55, 45, 40, 35, 30, 25, 21, 18, 15, 10, 5, 3, 1] + case 17...20: + return [75,65,60,55,50,45,40,35,30,25,23,20,18,15,12,10,5,3, 1] + case 21...24: + return [75,70,65,60,55,50,47,43,40,37,33,30,28,25,23,20,18,15,12,10,5,3, 1] + case 25...28: + return [80,75,70,65,60,55,53,50,48,45,43,40,38,35,33,30,28,25,23,20,18,15,12,10,5,3, 1] + case _ where count > 28: + return [80,75,72,70,65,63,60,58,55,53,50,48,45,43,40,38,35,33,30,28,25,23,20,18,15,12,10,8,5,3, 1] + default: + return [60,50,40,25,10,5] + } + case .p250: + switch count { + case 9...12: + return [163,138,125,88,63,50,38,25,13,8,3] + 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] + 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: + return [200,188,175,163,150,138,133,125,120,113,108,100,95,88,83,75,70,63,58,50,45,38,30,25,13,8,3] + case _ where count > 28: + return [200,188,180,175,163,158,150,145,138,133,125,120,113,108,100,95,88,83,75,70,63,58,50,45,38,30,25,20,13,8,3] + default: + return [150,125,100,63,25,13,3] + } + case .p500: + switch count { + case 9...12: + return [325,275,250,175,125,100,75,50,25,15,5] + case 13...16: + return [350,300,275,225,200,175,150,125,105,90,75,50,25,15,5] + case 17...20: + return [375,325,300,275,250,225,200,175,150,125,115,100,90,75,60,50,25,15,5] + case 21...24: + return [375,350,325,300,275,250,235,215,200,185,165,150,140,125,115,100,80,75,60,50,25,15,5] + case 25...28: + return [400,375,350,325,300,275,265,250,240,225,215,200,190,175,165,150,140,125,115,100,80,75,60,50,25,15,5] + case _ where count > 28: + return [400,375,360,350,325,315,300,290,275,265,250,240,225,215,200,190,175,165,150,140,125,115,100,80,75,60,50,40,25,15,5] + default: + return [300,250,200,125,50,25,5] + } + case .p1000: + switch count { + case 9...12: + return [650,550, 500, 350, 250, 200, 150, 100, 50, 30, 10] + case 13...16: + return [700, 600, 550, 450, 400, 350, 300, 250, 210, 180, 150, 100, 50, 30, 10] + case 17...20: + return [750,650,600,550,500,450,400,350,300,250,230,200,180,150,120,100,50,30, 10] + case 21...24: + return [750,700,650,600,550,500,470,430,400,370,330,300,280,250,230,200,180,150,120,100,50,30, 10] + case 25...28: + return [800,750,700,650,600,550,530,500,480,450,430,400,380,350,330,300,280,250,230,200,180,150,120,100,50,30, 10] + case _ where count > 28: + return [800,750,720,700,650,630,600,580,550,530,500,480,450,430,400,380,350,330,300,280,250,230,200,180,150,120,100,80,50,30, 10] + default: + return [600,500,400,250,100,50] + } + case .p1500: + switch count { + case 21...24: + return [1125,1050,975,900,825,750,705,645,600,555,495,450,420,375,345,300,270,225,180,150,75,45,15] + case 25...28: + return [1200,1125,1050,975,900,825,795,750,720,675,645,600,570,525,495,450,420,375,345,300,270,225,180,150,75,45,15] + case _ where count > 28: + return [1200,1125,1080,1050,975,945,900,870,825,795,750,720,675,645,600,570,525,495,450,420,375,345,300,270,225,180,150,120,75,45,15] + default: + return [1125,975,900,825,750,675,600,525,450,375,345,300,270,225,180,150,75,45,15] + } + case .p2000: + return [1600,1440,1300,1180,1120,1060,1000,880,840,800,760,720,680,640,600,540,520,500,480,460,440,420,400,260,200,40] + } + } + + public var ranges: [PlayersCountRange] { + switch self { + case .p1500: + return [.N16, .N24, .N28, .N32] + case .p2000: + return [.N32] + default: + return PlayersCountRange.allCases + } + } + +// enum NewBallSystem { +// case perField +// case perMatch(fromRound: Int?) +// +// func localizedLabel(loserBracket: Bool = false) -> String { +// switch self { +// case .perField: +// return "3 / piste" +// case .perMatch(let fromRound): +// if fromRound != nil { +// if loserBracket { +// return "3 / match pour les perdants des \(RoundLabel.shortLabels[fromRound!].lowercased())s" +// } else { +// return "3 / match à partir des \(RoundLabel.shortLabels[fromRound!].lowercased())s" +// } +// } else { +// return "3 / match" +// } +// } +// } +// } +// + func minimumFormatFinalTableAndQualifier(roundIndex: Int) -> MatchFormat? { + switch self { + case .p25, .unlisted, .championship: + return nil + case .p100: + return .nineGamesDecisivePoint + case .p250: + if roundIndex == 0 { //final + return .twoSetsDecisivePointSuperTie + } else { + return .nineGamesDecisivePoint + } + case .p500: + if roundIndex == 0 { //final + return .twoSetsDecisivePoint + } else if roundIndex == 1 { //demi + return .twoSetsDecisivePointSuperTie + } else { + return .nineGamesDecisivePoint + } + case .p1000, .p1500, .p2000: + if roundIndex == 3 { //16eme + return .twoSetsDecisivePoint + } else { + return .twoSetsDecisivePointSuperTie + } + } + } + + func minimumFormatLoserBracket(roundIndex: Int) -> MatchFormat? { + switch self { + case .p25, .unlisted, .championship: + return nil + case .p100, .p250, .p500: + return .nineGamesDecisivePoint + case .p1000, .p1500, .p2000: + if roundIndex == 1 { //demi + return .twoSetsDecisivePoint + } else { + return .nineGamesDecisivePoint + } + } + } + + +// func newBallsFinalTable() -> NewBallSystem? { +// switch self { +// case .p25, .p100: +// return .perField +// case .p250: +// return .perMatch(fromRound: 1) //demi +// case .p500: +// return .perMatch(fromRound: 2) //quart +// case .p1000, .p1500, .p2000: +// return .perMatch(fromRound: nil) +// } +// } +// +// func newBallsLoserBracket() -> NewBallSystem? { +// switch self { +// case .p25, .p100: +// return nil +// case .p250: +// return .perMatch(fromRound: 1) //demi +// case .p500, .p1000, .p1500, .p2000: +// return .perMatch(fromRound: 2) //quart +// } +// } +// +} + +public enum TournamentCategory: Int, Hashable, Codable, CaseIterable, Identifiable { + case men + case women + case mix + case unlisted + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public func mandatoryPlayerType() -> [Int] { + switch self { + case .unlisted: + return [] + case .mix: + return [0, 1] + case .women: + return [0, 0] + case .men: + return [1, 1] + } + } + + public var localizedPlayerLabel: String { + switch self { + case .women: + return "joueuse" + default: + return "joueur" + } + } + + public var showFemaleInMaleAssimilation: Bool { + switch self { + case .men: + return true + default: + return false + } + } + + public static func femaleInMaleAssimilationAddition(_ rank: Int) -> Int { + switch rank { + case 1...10: return 400 + case 11...30: return 1000 + case 31...60: return 2000 + case 61...100: return 3500 + case 101...200: return 10000 + case 201...500: return 15000 + case 501...1000: return 25000 + case 1001...2000: return 35000 + case 2001...3000: return 45000 + default: + return 50000 + } + } + + public static func mostRecent(inTournaments tournaments: [Tournament]) -> Self { + return tournaments.first?.tournamentCategory ?? .men + } + + public static func mostUsed(inTournaments tournaments: [Tournament]) -> Self { + let countedSet = NSCountedSet(array: tournaments.map { $0.tournamentCategory }) + let mostFrequent = countedSet.max { countedSet.count(for: $0) < countedSet.count(for: $1) } + if mostFrequent != nil { + return mostFrequent as! TournamentCategory + } else { + return mostRecent(inTournaments: tournaments) + } + } + + public var id: Int { self.rawValue } + + public var order: Int { + switch self { + case .unlisted: + return 0 + case .men: + return 1 + case .women: + return 2 + case .mix: + return 3 + } + } + + public var buildLabel: String { + switch self { + case .unlisted: + return "" + case .men: + return "H" + case .women: + return "D" + case .mix: + return "M" + } + } + + public var requestLabel: String { + switch self { + case .unlisted: + return "" + case .men: + return "DM" + case .women: + return "DD" + case .mix: + return "DX" + } + } + + public var importingRawValue: String { + switch self { + case .unlisted: + return "messieurs" + case .men: + return "messieurs" + case .women: + return "dames" + case .mix: + return "mixte" + } + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .unlisted: + return displayStyle == .title ? "Aucune" : "" + case .men: + switch displayStyle { + case .title: + return "Hommes" + case .wide: + return "Hommes" + case .short: + return "H" + } + case .women: + switch displayStyle { + case .title: + return "Dames" + case .wide: + return "Dames" + case .short: + return "D" + } + case .mix: + switch displayStyle { + case .title: + return "Mixte" + case .wide: + return "Mixte" + case .short: + return "MX" + } + } + } + + public var playerFilterOption: PlayerFilterOption { + switch self { + case .men, .unlisted: + return .all + case .women: + return .female + case .mix: + return .all + } + } +} + +public enum PlayerFilterOption: Int, Hashable, CaseIterable, Identifiable { + case all = -1 + case male = 1 + case female = 0 + + public var id: Int { rawValue } + + public func icon() -> String { + switch self { + case .all: + return "Tous" + case .male: + return "Homme" + case .female: + return "Femme" + } + } + + public var localizedPlayerLabel: String { + switch self { + case .female: + return "joueuse" + default: + return "joueur" + } + } + +} + + +public enum GroupStageOrderingMode: Int, Hashable, Codable, CaseIterable, Identifiable { + case random + case snake + case swiss + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public var id: Int { self.rawValue } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .random: + return "Au hasard" + case .snake: + return "En serpentin" + case .swiss: + return "Suisse" + } + } + + public var systemImage: String { + switch self { + case .random: + return "dice.fill" + case .snake: + return "arrow.triangle.swap" + case .swiss: + return "cross.fill" + } + } +} + +enum TournamentType: Int, Hashable, Codable, CaseIterable, Identifiable { + case classic + case doubleBrackets + + var id: Int { self.rawValue } + func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .classic: + return "Classique" + case .doubleBrackets: + return "Double Poules" + } + } +} + +public enum TeamPosition: Int, Identifiable, Hashable, Codable, CaseIterable { + case one + case two + + public var id: Int { self.rawValue } + + public var otherTeam: TeamPosition { + switch self { + case .one: + return .two + case .two: + return .one + } + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + var shortName: String { + switch self { + case .one: + return "#1" + case .two: + return "#2" + } + } + + switch displayStyle { + case .wide, .title: + return "Équipe " + shortName + case .short: + return shortName + } + } + + public func localizedBranchLabel() -> String { + switch self { + case .one: + return "Branche du haut" + case .two: + return "Branche du bas" + } + } +} + +public enum SetFormat: Int, Hashable, Codable { + case nine + case four + case six + case superTieBreak + case megaTieBreak + + public func shouldTiebreak(scoreTeamOne: Int, scoreTeamTwo: Int) -> Bool { + if let tieBreak { + return (scoreTeamOne + scoreTeamTwo) >= (2 * tieBreak) && (scoreTeamOne == tieBreak || scoreTeamTwo == tieBreak) + } else { + return false + } + } + + public func winner(teamOne: Int, teamTwo: Int) -> TeamPosition { + return teamOne > teamTwo ? .one : .two + } + + public func hasEnded(teamOne: Int, teamTwo: Int) -> Bool { + switch self { + case .nine: + if teamOne == 9 || teamTwo == 9 { + return true + } + case .four: + if teamOne == 5 || teamTwo == 5 { + return true + } + case .six: + if teamOne == 7 || teamTwo == 7 { + return true + } + case .superTieBreak, .megaTieBreak: + if teamOne == 1 || teamTwo == 1 { + return true + } + } + return (teamOne >= scoreToWin && teamOne >= teamTwo + 2) || (teamTwo >= scoreToWin && teamTwo >= teamOne + 2) + } + + public var tieBreak: Int? { + switch self { + case .nine: + return 8 + case .four: + return 4 + case .six: + return 6 + case .superTieBreak, .megaTieBreak: + return nil + } + } + + public func disableValuesForTeamTwo(with teamOneScore: Int) -> [Int] { + switch self { + case .nine: + if teamOneScore == 9 { + return [9] + } + case .four: + if teamOneScore == 4 { + return [] + } + case .six: + if teamOneScore == 6 { + return [] + } + case .superTieBreak: + if teamOneScore == 10 { + return [] + } + case .megaTieBreak: + if teamOneScore == 15 { + return [] + } + } + return [] + } + + public var possibleValues: [Int] { + switch self { + case .nine: + return [9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + case .four: + return [5, 4, 3, 2, 1, 0] + case .six: + return [7, 6, 5, 4, 3, 2, 1, 0] + case .superTieBreak: + return [10,9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + case .megaTieBreak: + return [15, 14, 13, 12, 11, 10,9, 8, 7, 6, 5, 4, 3, 2, 1, 0] + } + } + + public var scoreToWin: Int { + switch self { + case .nine: + return 9 + case .four: + return 4 + case .six: + return 6 + case .superTieBreak: + return 10 + case .megaTieBreak: + return 15 + } + } + + public var firstGameFormat: Format { + switch self { + case .megaTieBreak: + return .tiebreakFifteen + case .superTieBreak: + return .tiebreakTen + default: + return .normal + } + } +} + +public enum MatchType: String { + case bracket = "bracket" + case groupStage = "groupStage" + case loserBracket = "loserBracket" +} + +public enum MatchFormat: Int, Hashable, Codable, CaseIterable, Identifiable { + public var id: Int { self.rawValue } + case twoSets + case twoSetsSuperTie + case twoSetsOfFourGames + case nineGames + case superTie + case megaTie + + case twoSetsDecisivePoint + case twoSetsDecisivePointSuperTie + case twoSetsOfFourGamesDecisivePoint + case nineGamesDecisivePoint + + case twoSetsOfSuperTie + case singleSet + case singleSetDecisivePoint + case singleSetOfFourGames + case singleSetOfFourGamesDecisivePoint + + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + func defaultWalkOutScore(_ asWalkOutTeam: Bool) -> [Int] { + Array(repeating: asWalkOutTeam ? 0 : setFormat.scoreToWin, count: setsToWin) + } + + public var weight: Int { + switch self { + case .twoSets, .twoSetsDecisivePoint: + return 0 + case .twoSetsSuperTie, .twoSetsDecisivePointSuperTie: + return 1 + case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint: + return 2 + case .nineGames, .nineGamesDecisivePoint: + return 3 + case .superTie: + return 4 + case .megaTie: + return 5 + case .twoSetsOfSuperTie: + return 6 + case .singleSet, .singleSetDecisivePoint: + return 7 + case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return 8 + } + } + + var rank: Int { + switch self { + case .twoSets: + return 0 + case .twoSetsDecisivePoint: + return 0 + case .twoSetsSuperTie: + return 1 + case .twoSetsDecisivePointSuperTie: + return 1 + case .twoSetsOfFourGames: + return 2 + case .twoSetsOfFourGamesDecisivePoint: + return 2 + case .nineGames: + return 3 + case .nineGamesDecisivePoint: + return 3 + case .superTie: + return 4 + case .megaTie: + return 5 + case .twoSetsOfSuperTie: + return 6 + case .singleSet, .singleSetDecisivePoint: + return 7 + case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return 8 + } + } + + public static func defaultFormatForMatchType(_ matchType: MatchType) -> MatchFormat { + switch matchType { + case .bracket: + DataStore.shared.user.bracketMatchFormatPreference ?? .nineGamesDecisivePoint + case .groupStage: + DataStore.shared.user.groupStageMatchFormatPreference ?? .nineGamesDecisivePoint + case .loserBracket: + DataStore.shared.user.loserBracketMatchFormatPreference ?? .nineGamesDecisivePoint + } + } + + public static var allCases: [MatchFormat] { + [.twoSets, .twoSetsDecisivePoint, .twoSetsSuperTie, .twoSetsDecisivePointSuperTie, .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .nineGames, .nineGamesDecisivePoint, .superTie, .megaTie, .twoSetsOfSuperTie, .singleSet, .singleSetDecisivePoint, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint] + } + + public func winner(scoreTeamOne: Int, scoreTeamTwo: Int) -> TeamPosition { + scoreTeamOne >= scoreTeamTwo ? .one : .two + } + + public func hasEnded(scoreTeamOne: Int, scoreTeamTwo: Int) -> Bool { + scoreTeamOne == setsToWin || scoreTeamTwo == setsToWin + } + + public var canSuperTie: Bool { + switch self { + case .twoSetsSuperTie, .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .twoSetsDecisivePointSuperTie, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return true + default: + return false + } + } + + public func getEstimatedDuration(_ additionalDuration: Int = 0) -> Int { + estimatedDuration + additionalDuration + } + + private var estimatedDuration: Int { + DataStore.shared.user.matchFormatsDefaultDuration?[self] ?? defaultEstimatedDuration + } + + public func formattedEstimatedDuration(_ additionalDuration: Int = 0) -> String { + Duration.seconds((estimatedDuration + additionalDuration) * 60).formatted(.units(allowed: [.minutes])) + } + + public func formattedEstimatedBreakDuration() -> String { + var label = Duration.seconds(breakTime.breakTime * 60).formatted(.units(allowed: [.minutes])) + if breakTime.matchCount > 1 { + label += " de pause après \(breakTime.matchCount) match" + label += breakTime.matchCount.pluralSuffix + } else { + label += " de pause" + } + return label + } + + public var defaultEstimatedDuration: Int { + switch self { + case .twoSets: + return 105 + case .twoSetsDecisivePoint: + return 90 + case .twoSetsSuperTie: + return 80 + case .twoSetsDecisivePointSuperTie: + return 70 + case .twoSetsOfFourGames: + return 60 + case .twoSetsOfFourGamesDecisivePoint: + return 50 + case .nineGames: + return 45 + case .nineGamesDecisivePoint: + return 40 + case .megaTie: + return 20 + case .superTie: + return 15 + case .twoSetsOfSuperTie: + return 25 + case .singleSet: + return 30 + case .singleSetDecisivePoint: + return 25 + case .singleSetOfFourGames: + return 15 + case .singleSetOfFourGamesDecisivePoint: + return 10 + } + } + + var estimatedTimeWithBreak: Int { + estimatedDuration + breakTime.breakTime + } + + var breakTime: (breakTime: Int, matchCount: Int) { + switch self { + case .twoSets, .twoSetsDecisivePoint: + return (90, 1) + case .twoSetsSuperTie, .twoSetsDecisivePointSuperTie: + return (60, 1) + case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .nineGames, .nineGamesDecisivePoint: + return (30, 1) + case .superTie: + return (15, 3) + default: + return (5, 1) + } + } + + public func maximumMatchPerDay(for matchCount: Int) -> Int { + switch self { + case .twoSets, .twoSetsDecisivePoint: + return matchCount < 5 ? 2 : 0 + case .twoSetsSuperTie, .twoSetsDecisivePointSuperTie: + return matchCount < 6 ? 3 : 0 + case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .nineGames, .nineGamesDecisivePoint: + return matchCount < 7 ? 6 : 2 + case .superTie: + return 7 + default: + return 10 + } + } + + public var hasDecisivePoint: Bool { + switch self { + case .nineGamesDecisivePoint, .twoSetsDecisivePoint, .twoSetsOfFourGamesDecisivePoint, .twoSetsDecisivePointSuperTie, .singleSetDecisivePoint, .singleSetOfFourGamesDecisivePoint: + return true + default: + return false + } + } + + public func newSetFormat(setCount: Int) -> SetFormat { + if setCount == 2 && canSuperTie { + return .superTieBreak + } + return setFormat + } + + public func formatTitle(_ displayStyle: DisplayStyle = .wide) -> String { + switch displayStyle { + case .short: + return ["Format ", shortFormat].joined() + default: + return ["Format ", shortFormat, suffix].joined() + } + } + + var suffix: String { + switch self { + case .twoSetsDecisivePoint, .twoSetsDecisivePointSuperTie, .twoSetsOfFourGamesDecisivePoint, .nineGamesDecisivePoint, .singleSetDecisivePoint: + return " [Point Décisif]" + default: + return "" + } + } + + public var longPrefix: String { + return "Format \(format) : " + } + + public var shortPrefix: String { + return "\(format) : " + } + + public var isFederal: Bool { + switch self { + case .megaTie, .twoSetsOfSuperTie, .singleSet, .singleSetDecisivePoint, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return false + default: + return true + } + } + + public var format: String { + shortFormat + (isFederal ? "" : " (non officiel)") + } + public var shortFormat: String { + switch self { + case .twoSets: + return "A1" + case .twoSetsSuperTie: + return "B1" + case .twoSetsOfFourGames: + return "C1" + case .nineGames: + return "D1" + case .superTie: + return "E" + case .twoSetsOfSuperTie: + return "G" + case .megaTie: + return "F" + case .singleSet: + return "H1" + case .singleSetDecisivePoint: + return "H2" + case .twoSetsDecisivePoint: + return "A2" + case .twoSetsDecisivePointSuperTie: + return "B2" + case .twoSetsOfFourGamesDecisivePoint: + return "C2" + case .nineGamesDecisivePoint: + return "D2" + case .singleSetOfFourGames: + return "I1" + case .singleSetOfFourGamesDecisivePoint: + return "I2" + } + } + + public var longLabel: String { + switch self { + case .singleSet, .singleSetDecisivePoint: + return "1 set de 6" + case .twoSets, .twoSetsDecisivePoint: + return "2 sets de 6" + case .twoSetsSuperTie, .twoSetsDecisivePointSuperTie: + return "2 sets de 6, supertie au 3ème" + case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint: + return "2 sets de 4, tiebreak à 4/4, supertie au 3ème" + case .nineGames, .nineGamesDecisivePoint: + return "9 jeux, tiebreak à 8/8" + case .twoSetsOfSuperTie: + return "2 sets de supertie de 10 points" + case .superTie: + return "supertie de 10 points" + case .megaTie: + return "supertie de 15 points" + case .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return "1 set de 4 jeux, tiebreak à 4/4" + } + } + + var computedShortLabelWithoutPrefix: String { + longLabel + suffix + } + + var computedShortLabel: String { + shortPrefix + longLabel + suffix + } + + var computedLongLabel: String { + longPrefix + longLabel + suffix + } + + public var setsToWin: Int { + switch self { + case .twoSets, .twoSetsSuperTie, .twoSetsOfFourGames, .twoSetsDecisivePoint, .twoSetsOfFourGamesDecisivePoint, .twoSetsDecisivePointSuperTie, .twoSetsOfSuperTie: + return 2 + case .nineGames, .nineGamesDecisivePoint, .superTie, .megaTie, .singleSet, .singleSetDecisivePoint, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return 1 + } + } + + public var setFormat: SetFormat { + switch self { + case .twoSets, .twoSetsSuperTie, .twoSetsDecisivePoint, .twoSetsDecisivePointSuperTie, .singleSet, .singleSetDecisivePoint: + return .six + case .twoSetsOfFourGames, .twoSetsOfFourGamesDecisivePoint, .singleSetOfFourGames, .singleSetOfFourGamesDecisivePoint: + return .four + case .nineGames, .nineGamesDecisivePoint: + return .nine + case .superTie, .twoSetsOfSuperTie: + return .superTieBreak + case .megaTie: + return .megaTieBreak + } + } +} + +public enum Format: Int, Hashable, Codable { + case normal + case tiebreakSeven + case tiebreakTen + case tiebreakFifteen + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .normal: + return "normal" + case .tiebreakSeven: + return "tie-break en 7" + case .tiebreakTen: + return "tie-break en 10" + case .tiebreakFifteen: + return "tie-break en 15" + } + } + + public var isTiebreak: Bool { + switch self { + case .normal: + return false + case .tiebreakSeven, .tiebreakTen, .tiebreakFifteen: + return true + } + } + + public var scoreToWin: Int? { + switch self { + case .normal: + return nil + case .tiebreakSeven: + return 7 + case .tiebreakTen: + return 10 + case .tiebreakFifteen: + return 15 + } + } +} + +public enum ActionType: Int, Identifiable { + case fault + case winner + + public var id: Self { + self + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .fault: + return "Faute" + case .winner: + return "Point" + } + } + /* + Break points won + Break points + Errors + Smash winners + Smashes + Winners + Volley winners + + Total points won + % points won + Total break points + Break points won + Gold points won + Gold points won returning + Gold points won with service + Most consecutive points won + Total set points + Set points won + Match points + */ +} + +public enum EventType: Int, CaseIterable, Identifiable { + case approvedTournament + case friendlyTournament + case simulation + case animation + + public var id: Self { + self + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .approvedTournament: + return "Tournoi homologué" + case .friendlyTournament: + return "Tournoi amical" + case .simulation: + return "Simulation" + case .animation: + return "Animation" + } + } +} + +public enum TeamSortingType: Int, Identifiable, CaseIterable, Hashable, Codable { + case rank = 1 + case inscriptionDate = 2 + + public var id: Int { rawValue } + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .rank: + return "Rang" + case .inscriptionDate: + return "Date d'inscription" + } + } +} + +public enum PlayersCountRange: Int, CaseIterable { + case N8 = 8 + case N12 = 12 + case N16 = 16 + case N20 = 20 + case N24 = 24 + case N28 = 28 + case N32 = 32 + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .N8: + return "4 à 8" + case .N12: + return "9 à 12" + case .N16: + return "13 à 16" + case .N20: + return "17 à 20" + case .N24: + return "21 à 24" + case .N28: + return "25 à 28" + case .N32: + return "29 à 32" + } + } +} + +public enum RoundRule { + public static let colors = ["#99ff99", "#66ff66", "#33cc33", "#009900", "#006600", "#336633", "#DD6600", "#EE6633", "#EE6633", "#EE6633"] + + static func loserBrackets(index: Int) -> [String] { + switch index { + case 1: + return ["#3/#4"] + case 2: + return ["#5/#6", "#7/#8"] + default: + return ["#9/#10", "#11/#12", "#13/#14", "#15/#16", "#17/#18", "#19/#20", "#21/#22", "#23/#24", "#25/#26", "#27/#28", "#29/#30", "#31/#32"] + } + } + + static func teamsInFirstRound(forTeams teams: Int) -> Int { + Int(pow(2.0, ceil(log2(Double(teams))))) + } + + static func numberOfMatches(forTeams teams: Int) -> Int { + teamsInFirstRound(forTeams: teams) - 1 + } + + static func numberOfRounds(forTeams teams: Int) -> Int { + if teams == 0 { return 0 } + return Int(log2(Double(teamsInFirstRound(forTeams: teams)))) + } + + static func matchIndex(fromRoundIndex roundIndex: Int) -> Int { + guard roundIndex >= 0 else { + return -1 // Invalid round index + } + + return (1 << roundIndex) - 1 + } + + static func matchIndex(fromBracketPosition: Int) -> Int { + roundIndex(fromMatchIndex: fromBracketPosition / 2) + fromBracketPosition%2 + } + + public static func roundIndex(fromMatchIndex matchIndex: Int) -> Int { + Int(log2(Double(matchIndex + 1))) + } + + public static func numberOfMatches(forRoundIndex roundIndex: Int) -> Int { + Int(pow(2.0, Double(roundIndex))) + } + + static func matchIndexWithinRound(fromMatchIndex matchIndex: Int) -> Int { + let roundIndex = roundIndex(fromMatchIndex: matchIndex) + let matchIndexWithinRound = matchIndex - (Int(pow(2.0, Double(roundIndex))) - 1) + return matchIndexWithinRound + } + + public static func roundName(fromMatchIndex matchIndex: Int) -> String { + let roundIndex = roundIndex(fromMatchIndex: matchIndex) + return roundName(fromRoundIndex: roundIndex) + } + + public static func roundName(fromRoundIndex roundIndex: Int, displayStyle: DisplayStyle = .wide) -> String { + switch roundIndex { + case 0: + return "Finale" + case 1: + if displayStyle == .short { + return "Demi" + } + return "Demi-finale" + case 2: + if displayStyle == .short { + return "Quart" + } + return "Quart de finale" + default: + return "\(Int(pow(2.0, Double(roundIndex))))ème" + } + } +} + +public enum AnimationType: Int, CaseIterable, Hashable, Identifiable { + case playerAnimation + case upAndDown + case brawl + + public var id: Int { rawValue } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .playerAnimation: + return "Par joueur" + case .upAndDown: + return "Montante / Descendante" + case .brawl: + return "Brawl" + } + } + + public var descriptionLabel: String { + switch self { + case .playerAnimation: + return "Chaque joueur joue avec quelqu'un de différent à chaque rotation (8 à 12 joueurs)" + case .upAndDown: + return "Les gagnants montent sur le terrain d'à côté, les perdants descendent" + case .brawl: + return "A chaque rotation, les gagnants de la rotation précédente se jouent entre eux" + } + } +} + +public enum PadelTournamentStructurePreset: Int, Identifiable, CaseIterable { + public var id: Int { self.rawValue } + + case manual + case doubleGroupStage + case federalStructure_8 + case federalStructure_12 + case federalStructure_16 + case federalStructure_20 + case federalStructure_24 + case federalStructure_32 + case federalStructure_48 + case federalStructure_64 + + // Maximum qualified pairs based on the structure preset + public func tableDimension() -> Int { + switch self { + case .federalStructure_8: + return 8 + case .federalStructure_12: + return 12 + case .federalStructure_16: + return 16 + case .federalStructure_20: + return 20 + case .federalStructure_24: + return 24 + case .federalStructure_32: + return 32 + case .federalStructure_48: + return 48 + case .federalStructure_64: + return 64 + case .manual: + return 24 + case .doubleGroupStage: + return 9 + } + } + + // Wildcards allowed in the Qualifiers + public func wildcardBrackets() -> Int { + switch self { + case .federalStructure_8: + return 0 + case .federalStructure_12: + return 1 + case .federalStructure_16, .federalStructure_20, .federalStructure_24, .federalStructure_32: + return 2 + case .federalStructure_48, .federalStructure_64: + return 4 + case .manual, .doubleGroupStage: + return 0 + } + } + // Wildcards allowed in the Qualifiers + public func wildcardQualifiers() -> Int { + switch self { + case .federalStructure_8: + return 0 + case .federalStructure_12, .federalStructure_16: + return 1 + case .federalStructure_20, .federalStructure_24: + return 2 + case .federalStructure_32: + return 4 + case .federalStructure_48: + return 6 + case .federalStructure_64: + return 8 + case .manual, .doubleGroupStage: + return 0 + } + } + + // Number of teams admitted to the Qualifiers + public func teamsInQualifiers() -> Int { + switch self { + case .federalStructure_8: + return 8 + case .federalStructure_12: + return 12 + case .federalStructure_16: + return 16 + case .federalStructure_20: + return 20 + case .federalStructure_24: + return 24 + case .federalStructure_32: + return 32 + case .federalStructure_48: + return 48 + case .federalStructure_64: + return 64 + case .manual, .doubleGroupStage: + return 0 + } + } + + // Maximum teams that can qualify from the Qualifiers to the Final Table + public func maxTeamsFromQualifiers() -> Int { + switch self { + case .federalStructure_8, .federalStructure_12: + return 2 + case .federalStructure_16, .federalStructure_20, .federalStructure_24: + return 4 + case .federalStructure_32, .federalStructure_48, .federalStructure_64: + return 8 + case .manual, .doubleGroupStage: + return 0 + } + } + + public func localizedStructurePresetTitle() -> String { + switch self { + case .manual: + return "Défaut" + case .doubleGroupStage: + return "2 phases de poules" + case .federalStructure_8: + return "Structure fédérale 8" + case .federalStructure_12: + return "Structure fédérale 12" + case .federalStructure_16: + return "Structure fédérale 16" + case .federalStructure_20: + return "Structure fédérale 20" + case .federalStructure_24: + return "Structure fédérale 24" + case .federalStructure_32: + return "Structure fédérale 32" + case .federalStructure_48: + return "Structure fédérale 48" + case .federalStructure_64: + return "Structure fédérale 64" + } + } + + public func localizedDescriptionStructurePresetTitle() -> String { + switch self { + case .manual: + return "24 équipes, 4 poules de 4, 1 qualifié par poule" + case .doubleGroupStage: + return "Poules qui enchaînent sur une autre phase de poules: les premiers de chaque se retrouvent ensemble, puis les deuxièmes, etc." + case .federalStructure_8: + return "Tableau final à 8 paires, dont 2 qualifiées sortant de qualifications à 8 paires maximum. Aucune wildcard." + case .federalStructure_12, .federalStructure_16, .federalStructure_20, .federalStructure_24, .federalStructure_32, .federalStructure_48, .federalStructure_64: + return "Tableau final à \(tableDimension()) paires, dont \(maxTeamsFromQualifiers()) qualifiées sortant de qualifications à \(teamsInQualifiers()) paires maximum. \(wildcardBrackets()) wildcard\(wildcardBrackets().pluralSuffix) en tableau et \(wildcardQualifiers()) wildcard\(wildcardQualifiers().pluralSuffix) en qualifications." + } + } + + public func groupStageCount() -> Int { + switch self { + case .manual: + 4 + case .doubleGroupStage: + 3 + case .federalStructure_8: + 2 + case .federalStructure_12: + 2 + case .federalStructure_16: + 4 + case .federalStructure_20: + 4 + case .federalStructure_24: + 4 + case .federalStructure_32: + 8 + case .federalStructure_48: + 8 + case .federalStructure_64: + 8 + } + } + + public func teamsPerGroupStage() -> Int { + switch self { + case .manual: + 4 + case .doubleGroupStage: + 3 + case .federalStructure_8: + 4 + case .federalStructure_12: + 6 + case .federalStructure_16: + 4 + case .federalStructure_20: + 5 + case .federalStructure_24: + 6 + case .federalStructure_32: + 4 + case .federalStructure_48: + 6 + case .federalStructure_64: + 8 + } + } + + public func qualifiedPerGroupStage() -> Int { + switch self { + case .doubleGroupStage: + 0 + default: + 1 + } + } + + public func hasWildcards() -> Bool { + wildcardBrackets() > 0 || wildcardQualifiers() > 0 + } + + public func isFederalPreset() -> Bool { + switch self { + case .manual: + return false + case .doubleGroupStage: + return false + case .federalStructure_8, .federalStructure_12, .federalStructure_16, .federalStructure_20, .federalStructure_24, .federalStructure_32, .federalStructure_48, .federalStructure_64: + return true + } + } +} + +public enum TournamentDeadlineType: String, CaseIterable { + case inscription = "Inscription" + case broadcastList = "Publication de la liste" + case wildcardRequest = "Demande de WC" + case wildcardLicensePurchase = "Prise de licence des WC" + case definitiveBroadcastList = "Publication définitive" + + public func daysOffset(level: TournamentLevel) -> Int { + if level == .p500 { + switch self { + case .inscription: + return -6 + case .broadcastList: + return -6 + case .wildcardRequest: + return -4 + case .wildcardLicensePurchase, .definitiveBroadcastList: + return -4 + } + } else { + switch self { + case .inscription: + return -13 + case .broadcastList: + return -12 + case .wildcardRequest: + return -9 + case .wildcardLicensePurchase, .definitiveBroadcastList: + return -8 + } + + } + } + + public var timeOffset: DateComponents { + switch self { + case .broadcastList, .definitiveBroadcastList: + return DateComponents(hour: 12) + case .inscription, .wildcardRequest, .wildcardLicensePurchase: + return DateComponents(minute: -1) + } + } +} diff --git a/PadelClubData/Business/Screen.swift b/PadelClubData/Business/Screen.swift new file mode 100644 index 0000000..251ac6b --- /dev/null +++ b/PadelClubData/Business/Screen.swift @@ -0,0 +1,26 @@ +// +// Screen.swift +// PadelClub +// +// Created by Razmig Sarkissian on 03/03/2024. +// + +import Foundation + +public enum Screen: String, Codable { + case inscription + case groupStage + case round + case settings + case structure + case schedule + case cashier + case call + case rankings + case broadcast + case event + case print + case share + case restingTime + case stateSettings +} diff --git a/PadelClubData/Business/SeedInterval.swift b/PadelClubData/Business/SeedInterval.swift new file mode 100644 index 0000000..e9e7b0d --- /dev/null +++ b/PadelClubData/Business/SeedInterval.swift @@ -0,0 +1,72 @@ +// +// SeedInterval.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/04/2024. +// + +import Foundation + +public struct SeedInterval: Hashable, Comparable { + + public let first: Int + public let last: Int + + public init(first: Int, last: Int) { + self.first = first + self.last = last + } + + public func pointsRange(tournamentLevel: TournamentLevel, teamsCount: Int) -> String { + tournamentLevel.pointsRange(first: first, last: last, teamsCount: teamsCount) + } + + public static func <(lhs: SeedInterval, rhs: SeedInterval) -> Bool { + return lhs.first < rhs.first + } + + public func isFixed() -> Bool { + first == 1 && last == 2 + } + + public var count: Int { + dimension + } + + private var dimension: Int { + (last - (first - 1)) + } + + func chunks() -> [SeedInterval]? { + if dimension > 3 { + let split = dimension / 2 + if split%2 == 0 { + let firstHalf = SeedInterval(first: first, last: first + split - 1) + let secondHalf = SeedInterval(first: first + split, last: last) + return [firstHalf, secondHalf] + } else { + let firstHalf = SeedInterval(first: first, last: first + split) + let secondHalf = SeedInterval(first: first + split + 1, last: last) + return [firstHalf, secondHalf] + } + } else { + return nil + } + } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if dimension < 3 { + return "\(first)\(first.ordinalFormattedSuffix()) place" + } else { + return "Place \(first) à \(last)" + } + } + + public func localizedInterval(_ displayStyle: DisplayStyle = .wide) -> String { + if dimension < 3 { + return "#\(first) / #\(last)" + } else { + return "#\(first) à #\(last)" + } + } +} diff --git a/PadelClubData/Business/SpinDrawable.swift b/PadelClubData/Business/SpinDrawable.swift new file mode 100644 index 0000000..7888d2c --- /dev/null +++ b/PadelClubData/Business/SpinDrawable.swift @@ -0,0 +1,12 @@ +// +// SpinDrawable.swift +// PadelClubData +// +// Created by Laurent Morvillier on 15/04/2025. +// + +import Foundation + +public protocol SpinDrawable { + func segmentLabel(_ displayStyle: DisplayStyle, hideNames: Bool) -> [String] +} diff --git a/PadelClubData/ContactManager.swift b/PadelClubData/ContactManager.swift new file mode 100644 index 0000000..4cf957f --- /dev/null +++ b/PadelClubData/ContactManager.swift @@ -0,0 +1,71 @@ +// +// ContactManager.swift +// Padel Tournament +// +// Created by Razmig Sarkissian on 19/09/2023. +// + +import Foundation +import SwiftUI +import MessageUI +import LeStorage + +public enum ContactManagerError: LocalizedError { + case mailFailed + case mailNotSent //no network no error + case messageFailed + case messageNotSent //no network no error + case calendarAccessDenied + case calendarEventSaveFailed + case noCalendarAvailable + case uncalledTeams([TeamRegistration]) + + var localizedDescription: String { + switch self { + case .mailFailed: + return "Le mail n'a pas été envoyé" + case .mailNotSent: + return "Le mail est dans la boîte d'envoi de l'app Mail. Vérifiez son état dans l'app Mail avant d'essayer de le renvoyer." + case .messageFailed: + return "Le SMS n'a pas été envoyé" + case .messageNotSent: + return "Le SMS n'a pas été envoyé" + case .uncalledTeams(let array): + let verb = array.count > 1 ? "peuvent" : "peut" + return "Attention, \(array.count) équipe\(array.count.pluralSuffix) ne \(verb) pas être contacté par la méthode choisie" + case .calendarAccessDenied: + return "Padel Club n'a pas accès à votre calendrier" + case .calendarEventSaveFailed: + return "Padel Club n'a pas réussi à sauver ce tournoi dans votre calendrier" + case .noCalendarAvailable: + return "Padel Club n'a pas réussi à trouver un calendrier pour y inscrire ce tournoi" + } + } + + public static func getNetworkErrorMessage(sentError: ContactManagerError?, networkMonitorConnected: Bool) -> String { + var errors: [String] = [] + + if networkMonitorConnected == false { + errors.append("L'appareil n'est pas connecté à internet.") + } + if let sentError { + errors.append(sentError.localizedDescription) + } + return errors.joined(separator: "\n") + } +} + +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?) + + public var id: Int { + switch self { + case .message: return 0 + case .mail: return 1 + } + } + + public static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces." + +} diff --git a/PadelClubData/Data/.DS_Store b/PadelClubData/Data/.DS_Store new file mode 100644 index 0000000..5fc72cc Binary files /dev/null and b/PadelClubData/Data/.DS_Store differ diff --git a/PadelClubData/Data/AppSettings.swift b/PadelClubData/Data/AppSettings.swift new file mode 100644 index 0000000..f2fad36 --- /dev/null +++ b/PadelClubData/Data/AppSettings.swift @@ -0,0 +1,111 @@ +// +// AppSettings.swift +// PadelClub +// +// Created by Razmig Sarkissian on 26/03/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class AppSettings: MicroStorable { + + public var lastDataSource: String? = nil + public var didCreateAccount: Bool = false + public var didRegisterAccount: Bool = false + + //search tournament stuff + public var tournamentCategories: Set + public var tournamentLevels: Set + public var tournamentAges: Set + public var tournamentTypes: Set + public var startDate: Date + public var endDate: Date + public var city: String + public var distance: Double + public var sortingOption: String + public var nationalCup: Bool + public var dayDuration: Int? + public var dayPeriod: DayPeriod + + public func lastDataSourceDate() -> Date? { + guard let lastDataSource else { return nil } + return URL.importDateFormatter.date(from: lastDataSource) + } + + public func localizedLastDataSource() -> String? { + guard let lastDataSource else { return nil } + guard let date = URL.importDateFormatter.date(from: lastDataSource) else { return nil } + + return date.monthYearFormatted + } + + public func resetSearch() { + tournamentAges = Set() + tournamentTypes = Set() + tournamentLevels = Set() + tournamentCategories = Set() + city = "" + distance = 30 + startDate = Date() + endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())! + sortingOption = "dateDebut+asc" + nationalCup = false + dayDuration = nil + dayPeriod = .all + } + + public required init() { + tournamentAges = Set() + tournamentTypes = Set() + tournamentLevels = Set() + tournamentCategories = Set() + city = "" + distance = 30 + startDate = Date() + endDate = Calendar.current.date(byAdding: .month, value: 3, to: Date())! + sortingOption = "dateDebut+asc" + nationalCup = false + dayDuration = nil + dayPeriod = .all + } + + public required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + lastDataSource = try container.decodeIfPresent(String.self, forKey: ._lastDataSource) + didCreateAccount = try container.decodeIfPresent(Bool.self, forKey: ._didCreateAccount) ?? false + didRegisterAccount = try container.decodeIfPresent(Bool.self, forKey: ._didRegisterAccount) ?? false + tournamentCategories = try container.decodeIfPresent(Set.self, forKey: ._tournamentCategories) ?? Set() + tournamentLevels = try container.decodeIfPresent(Set.self, forKey: ._tournamentLevels) ?? Set() + tournamentAges = try container.decodeIfPresent(Set.self, forKey: ._tournamentAges) ?? Set() + tournamentTypes = try container.decodeIfPresent(Set.self, forKey: ._tournamentTypes) ?? Set() + startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? Date() + endDate = try container.decodeIfPresent(Date.self, forKey: ._endDate) ?? Calendar.current.date(byAdding: .month, value: 3, to: Date())! + city = try container.decodeIfPresent(String.self, forKey: ._city) ?? "" + distance = try container.decodeIfPresent(Double.self, forKey: ._distance) ?? 30 + sortingOption = try container.decodeIfPresent(String.self, forKey: ._sortingOption) ?? "dateDebut+asc" + nationalCup = try container.decodeIfPresent(Bool.self, forKey: ._nationalCup) ?? false + dayDuration = try container.decodeIfPresent(Int.self, forKey: ._dayDuration) + dayPeriod = try container.decodeIfPresent(DayPeriod.self, forKey: ._dayPeriod) ?? .all + } + + enum CodingKeys: String, CodingKey { + case _lastDataSource = "lastDataSource" + case _didCreateAccount = "didCreateAccount" + case _didRegisterAccount = "didRegisterAccount" + case _tournamentCategories = "tournamentCategories" + case _tournamentLevels = "tournamentLevels" + case _tournamentAges = "tournamentAges" + case _tournamentTypes = "tournamentTypes" + case _startDate = "startDate" + case _endDate = "endDate" + case _city = "city" + case _distance = "distance" + case _sortingOption = "sortingOption" + case _nationalCup = "nationalCup" + case _dayDuration = "dayDuration" + case _dayPeriod = "dayPeriod" + } +} diff --git a/PadelClubData/Data/Club.swift b/PadelClubData/Data/Club.swift new file mode 100644 index 0000000..2370b6a --- /dev/null +++ b/PadelClubData/Data/Club.swift @@ -0,0 +1,117 @@ +// +// Club.swift +// PadelClub +// +// Created by Laurent Morvillier on 02/02/2024. +// + +import Foundation +import SwiftUI +import LeStorage + +@Observable +public final class Club: BaseClub { + + static var copyServerResponse: Bool { return true } + + public func clubTitle(_ displayStyle: DisplayStyle = .wide) -> String { + switch displayStyle { + case .wide, .title: + return name + case .short: + return acronym + } + } + + public func shareURL() -> URL? { + return URL(string: URLs.main.url.appending(path: "?club=\(id)").absoluteString.removingPercentEncoding!) + } + + public var customizedCourts: [Court] { + DataStore.shared.courts.filter { $0.club == self.id }.sorted(by: \.index) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let customizedCourts = self.customizedCourts + for customizedCourt in customizedCourts { + customizedCourt.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + DataStore.shared.courts.deleteDependencies(customizedCourts, shouldBeSynchronized: shouldBeSynchronized) + } + +} + +extension Club { + public var isValid: Bool { + name.isEmpty == false && name.count > 3 + } + + public func automaticShortName() -> String { + name.acronym() + } + + public enum AcronymMode: String, CaseIterable { + case automatic = "Automatique" + case custom = "Personalisée" + } + + public func shortNameMode() -> AcronymMode { + (acronym.isEmpty || acronym == automaticShortName()) ? .automatic : .custom + } + + public func hasTenupId() -> Bool { + code != nil + } + + public func federalLink() -> URL? { + guard let code else { return nil } + return URL(string: "https://tenup.fft.fr/club/\(code)") + } + + public func courtName(atIndex courtIndex: Int) -> String { + courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) + } + + public func courtNameIfAvailable(atIndex courtIndex: Int) -> String? { + customizedCourts.first(where: { $0.index == courtIndex })?.name + } + + public 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 + } + + public func hasBeenCreated(by creatorId: String?) -> Bool { + return creatorId == creator || creator == nil || self.relatedUser == creatorId + } + + public func isFavorite() -> Bool { + return DataStore.shared.user.clubs.contains(where: { $0 == self.id }) + } + + public 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 { + let club = Club(creator: StoreCenter.main.userId, name: name, acronym: name.acronym(), code: code, city: city, zipCode: zipCode) + club.relatedUser = StoreCenter.main.userId + return club + } + } + +} diff --git a/PadelClubData/Data/Court.swift b/PadelClubData/Data/Court.swift new file mode 100644 index 0000000..437847d --- /dev/null +++ b/PadelClubData/Data/Court.swift @@ -0,0 +1,58 @@ +// +// Court.swift +// PadelClub +// +// Created by Razmig Sarkissian on 23/04/2024. +// + +import Foundation +import SwiftUI +import LeStorage + +@Observable +public final class Court: BaseCourt { + + static func == (lhs: Court, rhs: Court) -> Bool { + lhs.id == rhs.id + } + + public init(index: Int, club: String, name: String? = nil, exitAllowed: Bool = false, indoor: Bool = false) { + + super.init() + + self.index = index + self.lastUpdate = Date() + self.club = club + self.name = name + self.exitAllowed = exitAllowed + self.indoor = indoor + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public func courtTitle() -> String { + self.name ?? courtIndexTitle() + } + + public func courtIndexTitle() -> String { + Self.courtIndexedTitle(atIndex: index) + } + + public static func courtIndexedTitle(atIndex index: Int) -> String { + ("Terrain #" + (index + 1).formatted()) + } + + public func clubObject() -> Club? { + Store.main.findById(club) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + } + +} diff --git a/PadelClubData/Data/CustomUser.swift b/PadelClubData/Data/CustomUser.swift new file mode 100644 index 0000000..a7154e6 --- /dev/null +++ b/PadelClubData/Data/CustomUser.swift @@ -0,0 +1,268 @@ +// +// 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 +public class CustomUser: BaseCustomUser, UserBase { + +// 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() +// var lastUpdate: Date +// public var username: String +// public var email: String +// var clubs: [String] = [] +// var umpireCode: String? +// var licenceId: String? +// var firstName: String +// var lastName: String +// var phone: String? +// var country: String? +// +// var summonsMessageBody : String? = nil +// var summonsMessageSignature: String? = nil +// var summonsAvailablePaymentMethods: String? = nil +// var summonsDisplayFormat: Bool = false +// var summonsDisplayEntryFee: Bool = false +// var summonsUseFullCustomMessage: Bool = false +// var matchFormatsDefaultDuration: [MatchFormat: Int]? = nil +// var bracketMatchFormatPreference: MatchFormat? +// var groupStageMatchFormatPreference: MatchFormat? +// var loserBracketMatchFormatPreference: MatchFormat? +// var loserBracketMode: LoserBracketMode = .automatic +// +// var deviceId: String? + + init(username: String, email: String, firstName: String, lastName: String, phone: String?, country: String?, loserBracketMode: LoserBracketMode = .automatic) { + super.init(username: username, email: email, firstName: firstName, lastName: lastName, phone: phone, country: country, loserBracketMode: loserBracketMode) + + +// self.lastUpdate = Date() +// self.username = username +// self.firstName = firstName +// self.lastName = lastName +// self.email = email +// self.phone = phone +// self.country = country +// self.loserBracketMode = loserBracketMode + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public func uuid() throws -> UUID { + if let uuid = UUID(uuidString: self.id) { + return uuid + } + throw UUIDError.cantConvertString(string: self.id) + } + + public func defaultSignature(_ tournament: Tournament?) -> String { + let fullName = tournament?.umpireCustomContact ?? fullName() + return "Sportivement,\n\(fullName), votre JAP." + } + + public func fullName() -> String { + [firstName, lastName].joined(separator: " ") + } + + public func hasTenupClubs() -> Bool { + self.clubsObjects().filter({ $0.code != nil }).isEmpty == false + } + + public func hasFavoriteClubsAndCreatedClubs() -> Bool { + clubsObjects(includeCreated: true).isEmpty == false + } + + public func setUserClub(_ userClub: Club) { + self.clubs.insert(userClub.id, at: 0) + } + + public func clubsObjects(includeCreated: Bool = false) -> [Club] { + return DataStore.shared.clubs.filter({ (includeCreated && $0.creator == id) || clubs.contains($0.id) }) + } + + public func createdClubsObjectsNotFavorite() -> [Club] { + return DataStore.shared.clubs.filter({ ($0.creator == id) && clubs.contains($0.id) == false }) + } + + public 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) + } + } + + public func getSummonsMessageSignature() -> String? { + if let summonsMessageSignature, summonsMessageSignature.isEmpty == false { + return summonsMessageSignature + } else { + return nil + } + } + +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _lastUpdate = "lastUpdate" +// case _username = "username" +// case _email = "email" +// case _clubs = "clubs" +// case _umpireCode = "umpireCode" +// case _licenceId = "licenceId" +// case _firstName = "firstName" +// case _lastName = "lastName" +// case _phone = "phone" +// case _country = "country" +// case _summonsMessageBody = "summonsMessageBody" +// case _summonsMessageSignature = "summonsMessageSignature" +// case _summonsAvailablePaymentMethods = "summonsAvailablePaymentMethods" +// case _summonsDisplayFormat = "summonsDisplayFormat" +// case _summonsDisplayEntryFee = "summonsDisplayEntryFee" +// case _summonsUseFullCustomMessage = "summonsUseFullCustomMessage" +// case _matchFormatsDefaultDuration = "matchFormatsDefaultDuration" +// case _bracketMatchFormatPreference = "bracketMatchFormatPreference" +// case _groupStageMatchFormatPreference = "groupStageMatchFormatPreference" +// case _loserBracketMatchFormatPreference = "loserBracketMatchFormatPreference" +// case _deviceId = "deviceId" +// case _loserBracketMode = "loserBracketMode" +// } +// +// public required init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// +// // Required properties +// id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() +// lastUpdate = try container.decodeIfPresent(Date.self, forKey: ._lastUpdate) ?? Date() +// username = try container.decode(String.self, forKey: ._username) +// email = try container.decode(String.self, forKey: ._email) +// firstName = try container.decode(String.self, forKey: ._firstName) +// lastName = try container.decode(String.self, forKey: ._lastName) +// +// // Optional properties +// clubs = try container.decodeIfPresent([String].self, forKey: ._clubs) ?? [] +// umpireCode = try container.decodeIfPresent(String.self, forKey: ._umpireCode) +// licenceId = try container.decodeIfPresent(String.self, forKey: ._licenceId) +// phone = try container.decodeIfPresent(String.self, forKey: ._phone) +// country = try container.decodeIfPresent(String.self, forKey: ._country) +// +// // Summons-related properties +// summonsMessageBody = try container.decodeIfPresent(String.self, forKey: ._summonsMessageBody) +// summonsMessageSignature = try container.decodeIfPresent(String.self, forKey: ._summonsMessageSignature) +// summonsAvailablePaymentMethods = try container.decodeIfPresent(String.self, forKey: ._summonsAvailablePaymentMethods) +// summonsDisplayFormat = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayFormat) ?? false +// summonsDisplayEntryFee = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayEntryFee) ?? false +// summonsUseFullCustomMessage = try container.decodeIfPresent(Bool.self, forKey: ._summonsUseFullCustomMessage) ?? false +// +// // Match-related properties +// matchFormatsDefaultDuration = try container.decodeIfPresent([MatchFormat: Int].self, forKey: ._matchFormatsDefaultDuration) +// bracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._bracketMatchFormatPreference) +// groupStageMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._groupStageMatchFormatPreference) +// loserBracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._loserBracketMatchFormatPreference) +// loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic +// } +// +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// +// try container.encode(id, forKey: ._id) +// try container.encode(lastUpdate, forKey: ._lastUpdate) +// try container.encode(username, forKey: ._username) +// try container.encode(email, forKey: ._email) +// try container.encode(clubs, forKey: ._clubs) +// +// try container.encode(umpireCode, forKey: ._umpireCode) +// try container.encode(licenceId, forKey: ._licenceId) +// try container.encode(firstName, forKey: ._firstName) +// try container.encode(lastName, forKey: ._lastName) +// try container.encode(phone, forKey: ._phone) +// try container.encode(country, forKey: ._country) +// try container.encode(summonsMessageBody, forKey: ._summonsMessageBody) +// try container.encode(summonsMessageSignature, forKey: ._summonsMessageSignature) +// try container.encode(summonsAvailablePaymentMethods, forKey: ._summonsAvailablePaymentMethods) +// try container.encode(summonsDisplayFormat, forKey: ._summonsDisplayFormat) +// try container.encode(summonsDisplayEntryFee, forKey: ._summonsDisplayEntryFee) +// try container.encode(summonsUseFullCustomMessage, forKey: ._summonsUseFullCustomMessage) +// +// try container.encode(matchFormatsDefaultDuration, forKey: ._matchFormatsDefaultDuration) +// try container.encode(bracketMatchFormatPreference, forKey: ._bracketMatchFormatPreference) +// try container.encode(groupStageMatchFormatPreference, forKey: ._groupStageMatchFormatPreference) +// try container.encode(loserBracketMatchFormatPreference, forKey: ._loserBracketMatchFormatPreference) +// try container.encode(deviceId, forKey: ._deviceId) +// +// try container.encode(loserBracketMode, forKey: ._loserBracketMode) +// } + + static func placeHolder() -> CustomUser { + return CustomUser(username: "", email: "", firstName: "", lastName: "", phone: nil, country: nil) + } + +} + +public class UserCreationForm: CustomUser, UserPasswordBase { + + public init(user: CustomUser, 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") + } + + required public init() { + self.password = "" + super.init() + } + + public var password: String + + private enum CodingKeys: String, CodingKey { + case password + } + + public 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) + } +} diff --git a/PadelClubData/Data/DataStore.swift b/PadelClubData/Data/DataStore.swift new file mode 100644 index 0000000..67f36f3 --- /dev/null +++ b/PadelClubData/Data/DataStore.swift @@ -0,0 +1,331 @@ +// +// DataStore.swift +// PadelClub +// +// Created by Laurent Morvillier on 02/02/2024. +// + +import Foundation +import LeStorage +import SwiftUI +import Combine + +public class DataStore: ObservableObject { + + public static let shared = DataStore() + + @Published public var user: CustomUser = CustomUser.placeHolder() { + didSet { + let loggedUser = StoreCenter.main.isAuthenticated + + if loggedUser { + if self.user.id != self.userStorage.item()?.id { + self.userStorage.setItemNoSync(self.user) + StoreCenter.main.initialSynchronization(clear: false) + self._fixMissingClubCreatorIfNecessary(self.clubs) + self._fixMissingEventCreatorIfNecessary(self.events) + } + } else { + self._temporaryLocalUser.item = self.user + } + } + } + + public fileprivate(set) var tournaments: SyncedCollection + public fileprivate(set) var clubs: SyncedCollection + public fileprivate(set) var courts: SyncedCollection + public fileprivate(set) var events: SyncedCollection + public fileprivate(set) var monthData: StoredCollection + public fileprivate(set) var dateIntervals: SyncedCollection + public fileprivate(set) var purchases: SyncedCollection + + fileprivate var userStorage: StoredSingleton + + fileprivate var _temporaryLocalUser: OptionalStorage = OptionalStorage(fileName: "tmp_local_user.json") + public fileprivate(set) var appSettingsStorage: MicroStorage = MicroStorage(fileName: "appsettings.json") + + public var appSettings: AppSettings { + appSettingsStorage.item + } + + init() { + + let store = Store.main + + let indexed: Bool = true + self.clubs = store.registerSynchronizedCollection(indexed: indexed) + self.courts = store.registerSynchronizedCollection(indexed: indexed) + self.tournaments = store.registerSynchronizedCollection(indexed: indexed) + self.events = store.registerSynchronizedCollection(indexed: indexed) + self.dateIntervals = store.registerSynchronizedCollection(indexed: indexed) + self.userStorage = store.registerObject(synchronized: true) + self.purchases = Store.main.registerSynchronizedCollection(inMemory: true) + + self.monthData = store.registerCollection(indexed: indexed) + + // Load ApiCallCollection, making them restart at launch and deletable on disconnect + StoreCenter.main.loadApiCallCollection(type: GroupStage.self) + StoreCenter.main.loadApiCallCollection(type: Round.self) + StoreCenter.main.loadApiCallCollection(type: PlayerRegistration.self) + StoreCenter.main.loadApiCallCollection(type: TeamRegistration.self) + StoreCenter.main.loadApiCallCollection(type: Match.self) + StoreCenter.main.loadApiCallCollection(type: TeamScore.self) + StoreCenter.main.loadApiCallCollection(type: DrawLog.self) + + NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(collectionDidUpdate), name: NSNotification.Name.CollectionDidChange, object: nil) + NotificationCenter.default.addObserver( + self, + selector: #selector(_willEnterForegroundNotification), + name: UIScene.willEnterForegroundNotification, + object: nil) + + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + public func saveUser() { + if user.username.count > 0 { + self.userStorage.update() + } else { + self._temporaryLocalUser.item = self.user + } + } + + @objc func collectionDidLoad(notification: Notification) { + + if let userSingleton: StoredSingleton = notification.object as? StoredSingleton { + self.user = userSingleton.item() ?? self._temporaryLocalUser.item ?? CustomUser.placeHolder() + } else if let clubsCollection: SyncedCollection = notification.object as? SyncedCollection { + self._fixMissingClubCreatorIfNecessary(clubsCollection) + } else if let eventsCollection: SyncedCollection = notification.object as? SyncedCollection { + self._fixMissingEventCreatorIfNecessary(eventsCollection) + } + + if Store.main.fileCollectionsAllLoaded() { + AutomaticPatcher.applyAllWhenApplicable() + } + + } + + fileprivate func _fixMissingClubCreatorIfNecessary(_ clubsCollection: SyncedCollection) { + for club in clubsCollection { + if let userId = StoreCenter.main.userId, club.creator == nil { + club.creator = userId + self.userStorage.item()?.addClub(club) + self.userStorage.update() + clubsCollection.writeChangeAndInsertOnServer(instance: club) + } + } + } + + fileprivate func _fixMissingEventCreatorIfNecessary(_ eventsCollection: SyncedCollection) { + 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() + } + + @objc func _willEnterForegroundNotification() { + Task { + try await self.purchases.loadDataFromServerIfAllowed(clear: true) + } + } + + public 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() + } + } + + } + + public func deleteAccount() { + + Task { + do { + let services = try StoreCenter.main.service() + try await services.deleteAccount() + } catch { + Logger.error(error) + } + + DispatchQueue.main.async { + self._localDisconnect() + } + + } + + } + + public func deleteTournament(_ tournament: Tournament) { + let event = tournament.eventObject() + let isLastTournament = event?.tournaments.count == 1 + self.tournaments.delete(instance: tournament) + if let event, isLastTournament { + self.events.delete(instance: event) + } + StoreCenter.main.destroyStore(identifier: tournament.id) + } + + fileprivate func _localDisconnect() { + + let tournamendIds: [String] = self.tournaments.map { $0.id } + + TournamentLibrary.shared.reset() + + self.tournaments.reset() + self.clubs.reset() + self.courts.reset() + self.events.reset() + self.dateIntervals.reset() + self.userStorage.reset() + self.purchases.reset() + + Guard.main.disconnect() + + StoreCenter.main.disconnect() + + for tournament in tournamendIds { + StoreCenter.main.destroyStore(identifier: tournament.id) + } + + self.user = self._temporaryLocalUser.item ?? CustomUser.placeHolder() + self.user.clubs.removeAll() + + } + +// 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 _: CustomUser = 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 + + public func runningMatches() -> [Match] { + let dateNow : Date = Date() + let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10) + + var runningMatches: [Match] = [] + for tournament in lastTournaments { + if let store = tournament.tournamentStore { + let matches = store.matches.filter { match in + match.disabled == false && match.isRunning() + } + runningMatches.append(contentsOf: matches) + } + } + return runningMatches + } + + public func runningAndNextMatches() -> [Match] { + let dateNow : Date = Date() + let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10) + + var runningMatches: [Match] = [] + for tournament in lastTournaments { + if let store = tournament.tournamentStore { + let matches = store.matches.filter { match in + match.disabled == false && match.startDate != nil && match.endDate == nil } + runningMatches.append(contentsOf: matches) + } + } + return runningMatches + } + + public func endMatches() -> [Match] { + let dateNow : Date = Date() + let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow && $0.hasEnded() == false }.sorted(by: \Tournament.startDate, order: .descending).prefix(10) + + var runningMatches: [Match] = [] + for tournament in lastTournaments { + if let store = tournament.tournamentStore { + let matches = store.matches.filter { match in + match.disabled == false && match.hasEnded() } + runningMatches.append(contentsOf: matches) + } + } + return runningMatches.sorted(by: \.endDate!, order: .descending) + } + +} diff --git a/PadelClubData/Data/DateInterval.swift b/PadelClubData/Data/DateInterval.swift new file mode 100644 index 0000000..6352641 --- /dev/null +++ b/PadelClubData/Data/DateInterval.swift @@ -0,0 +1,76 @@ +// +// DateInterval.swift +// PadelClub +// +// Created by Razmig Sarkissian on 19/04/2024. +// + +import Foundation +import SwiftUI +import LeStorage + +@Observable +public final class DateInterval: BaseDateInterval { + +// 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 lastUpdate: Date +// var event: String +// var courtIndex: Int +// var startDate: Date +// var endDate: Date + + public init(event: String, courtIndex: Int, startDate: Date, endDate: Date) { + super.init(event: event, courtIndex: courtIndex, startDate: startDate, endDate: endDate) +// self.lastUpdate = Date() +// self.event = event +// self.courtIndex = courtIndex +// self.startDate = startDate +// self.endDate = endDate + } + + required init(from decoder: any Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + var range: Range { + startDate.. 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 + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + } + +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _lastUpdate = "lastUpdate" +// case _event = "event" +// case _courtIndex = "courtIndex" +// case _startDate = "startDate" +// case _endDate = "endDate" +// } + + func insertOnServer() throws { + DataStore.shared.dateIntervals.writeChangeAndInsertOnServer(instance: self) + } + +} diff --git a/PadelClubData/Data/DrawLog.swift b/PadelClubData/Data/DrawLog.swift new file mode 100644 index 0000000..e663219 --- /dev/null +++ b/PadelClubData/Data/DrawLog.swift @@ -0,0 +1,97 @@ +// +// DrawLog.swift +// PadelClub +// +// Created by razmig on 22/10/2024. +// + +import Foundation +import SwiftUI +import LeStorage + +@Observable +public final class DrawLog: BaseDrawLog, SideStorable { + + public func tournamentObject() -> Tournament? { + Store.main.findById(self.tournament) + } + + public func computedBracketPosition() -> Int { + drawMatchIndex * 2 + drawTeamPosition.rawValue + } + + public func updateTeamBracketPosition(_ team: TeamRegistration) { + guard let match = drawMatch() else { return } + let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: drawTeamPosition) + team.bracketPosition = seedPosition + tournamentObject()?.updateTeamScores(in: seedPosition) + } + + public func exportedDrawLog() -> String { + [drawType.localizedDrawType(), drawDate.localizedDate(), localizedDrawLogLabel(), localizedDrawBranch()].filter({ $0.isEmpty == false }).joined(separator: " ") + } + + public func localizedDrawSeedLabel() -> String { + return "\(drawType.localizedDrawType()) #\(drawSeed + 1)" + } + + public func localizedDrawLogLabel() -> String { + return [localizedDrawSeedLabel(), positionLabel()].filter({ $0.isEmpty == false }).joined(separator: " -> ") + } + + public func localizedDrawBranch() -> String { + switch drawType { + case .seed: + return drawTeamPosition.localizedBranchLabel() + default: + return "" + } + } + + public func drawMatch() -> Match? { + switch drawType { + case .seed: + let roundIndex = RoundRule.roundIndex(fromMatchIndex: drawMatchIndex) + return tournamentStore?.rounds.first(where: { $0.parent == nil && $0.index == roundIndex })?._matches().first(where: { $0.index == drawMatchIndex }) + default: + return nil + } + } + + public func positionLabel() -> String { + return drawMatch()?.roundAndMatchTitle() ?? "" + } + + public func roundLabel() -> String { + return drawMatch()?.roundTitle() ?? "" + } + + public func matchLabel() -> String { + return drawMatch()?.matchTitle() ?? "" + } + + public var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.tournament) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + } + +} + +public enum DrawType: Int, Codable { + case seed + case groupStage + case court + + func localizedDrawType() -> String { + switch self { + case .seed: + return "Tête de série" + case .groupStage: + return "Poule" + case .court: + return "Terrain" + } + } +} diff --git a/PadelClubData/Data/Event.swift b/PadelClubData/Data/Event.swift new file mode 100644 index 0000000..de202f5 --- /dev/null +++ b/PadelClubData/Data/Event.swift @@ -0,0 +1,100 @@ +// +// Event_v2.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class Event: BaseEvent { + + public init(creator: String? = nil, club: String? = nil, name: String? = nil, tenupId: String? = nil) { + super.init(creator: creator, club: club, name: name, tenupId: tenupId) + self.relatedUser = creator + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let tournaments = self.tournaments + for tournament in tournaments { + tournament.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + + DataStore.shared.tournaments.deleteDependencies(tournaments, shouldBeSynchronized: shouldBeSynchronized) + + let courtsUnavailabilities = self.courtsUnavailability + for courtsUnavailability in courtsUnavailabilities { + courtsUnavailability.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + DataStore.shared.dateIntervals.deleteDependencies(courtsUnavailabilities, shouldBeSynchronized: shouldBeSynchronized) + } + + // MARK: - Computed dependencies + + public var tournaments: [Tournament] { + DataStore.shared.tournaments.filter { $0.event == self.id && $0.isDeleted == false } + } + + public func clubObject() -> Club? { + guard let club else { return nil } + return Store.main.findById(club) + } + + public var courtsUnavailability: [DateInterval] { + DataStore.shared.dateIntervals.filter({ $0.event == id }) + } + + // MARK: - + + public func eventCourtCount() -> Int { + tournaments.map { $0.courtCount }.max() ?? 2 + } + + public func eventStartDate() -> Date { + tournaments.map { $0.startDate }.min() ?? Date() + } + + public func eventDayDuration() -> Int { + tournaments.map { $0.dayDuration }.max() ?? 1 + } + + public func eventTitle() -> String { + if let name, name.isEmpty == false { + return name + } else { + return "Événement" + } + } + + public func existingBuild(_ build: any TournamentBuildHolder) -> Tournament? { + tournaments.first(where: { $0.isSameBuild(build) }) + } + + public func tournamentsCourtsUsed(exluding tournamentId: String) -> [DateInterval] { + tournaments.filter { $0.id != tournamentId }.flatMap({ tournament in + tournament.getPlayedMatchDateIntervals(in: self) + }) + } + + func insertOnServer() throws { + DataStore.shared.events.writeChangeAndInsertOnServer(instance: self) + for tournament in self.tournaments { + try tournament.insertOnServer() + } + for dataInterval in self.courtsUnavailability { + try dataInterval.insertOnServer() + } + } + +} diff --git a/PadelClubData/Data/Gen/BaseClub.swift b/PadelClubData/Data/Gen/BaseClub.swift new file mode 100644 index 0000000..c98952d --- /dev/null +++ b/PadelClubData/Data/Gen/BaseClub.swift @@ -0,0 +1,149 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseClub: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "clubs" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var creator: String? = nil + public var name: String = "" + public var acronym: String = "" + public var phone: String? = nil + public var code: String? = nil + public var address: String? = nil + public var city: String? = nil + public var zipCode: String? = nil + public var latitude: Double? = nil + public var longitude: Double? = nil + public var courtCount: Int = 2 + public var broadcastCode: String? = nil + public var timezone: String? = TimeZone.current.identifier + + public init( + id: String = Store.randomId(), + creator: String? = nil, + name: String = "", + acronym: String = "", + phone: String? = nil, + code: String? = nil, + address: String? = nil, + city: String? = nil, + zipCode: String? = nil, + latitude: Double? = nil, + longitude: Double? = nil, + courtCount: Int = 2, + broadcastCode: String? = nil, + timezone: String? = TimeZone.current.identifier + ) { + super.init() + self.id = id + self.creator = creator + self.name = name + self.acronym = acronym + self.phone = phone + self.code = code + self.address = address + self.city = city + self.zipCode = zipCode + self.latitude = latitude + self.longitude = longitude + self.courtCount = courtCount + self.broadcastCode = broadcastCode + self.timezone = timezone + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _creator = "creator" + case _name = "name" + case _acronym = "acronym" + case _phone = "phone" + case _code = "code" + case _address = "address" + case _city = "city" + case _zipCode = "zipCode" + case _latitude = "latitude" + case _longitude = "longitude" + case _courtCount = "courtCount" + case _broadcastCode = "broadcastCode" + case _timezone = "timezone" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.creator = try container.decodeIfPresent(String.self, forKey: ._creator) ?? nil + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? "" + self.acronym = try container.decodeIfPresent(String.self, forKey: ._acronym) ?? "" + self.phone = try container.decodeIfPresent(String.self, forKey: ._phone) ?? nil + self.code = try container.decodeIfPresent(String.self, forKey: ._code) ?? nil + self.address = try container.decodeIfPresent(String.self, forKey: ._address) ?? nil + self.city = try container.decodeIfPresent(String.self, forKey: ._city) ?? nil + self.zipCode = try container.decodeIfPresent(String.self, forKey: ._zipCode) ?? nil + self.latitude = try container.decodeIfPresent(Double.self, forKey: ._latitude) ?? nil + self.longitude = try container.decodeIfPresent(Double.self, forKey: ._longitude) ?? nil + self.courtCount = try container.decodeIfPresent(Int.self, forKey: ._courtCount) ?? 2 + self.broadcastCode = try container.decodeIfPresent(String.self, forKey: ._broadcastCode) ?? nil + self.timezone = try container.decodeIfPresent(String.self, forKey: ._timezone) ?? TimeZone.current.identifier + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.creator, forKey: ._creator) + try container.encode(self.name, forKey: ._name) + try container.encode(self.acronym, forKey: ._acronym) + try container.encode(self.phone, forKey: ._phone) + try container.encode(self.code, forKey: ._code) + try container.encode(self.address, forKey: ._address) + try container.encode(self.city, forKey: ._city) + try container.encode(self.zipCode, forKey: ._zipCode) + try container.encode(self.latitude, forKey: ._latitude) + try container.encode(self.longitude, forKey: ._longitude) + try container.encode(self.courtCount, forKey: ._courtCount) + try container.encode(self.broadcastCode, forKey: ._broadcastCode) + try container.encode(self.timezone, forKey: ._timezone) + try super.encode(to: encoder) + } + + func creatorValue() -> CustomUser? { + guard let creator = self.creator else { return nil } + return Store.main.findById(creator) + } + + public func copy(from other: any Storable) { + guard let club = other as? BaseClub else { return } + self.id = club.id + self.creator = club.creator + self.name = club.name + self.acronym = club.acronym + 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 + self.courtCount = club.courtCount + self.broadcastCode = club.broadcastCode + self.timezone = club.timezone + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: CustomUser.self, keyPath: \BaseClub.creator), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseCourt.swift b/PadelClubData/Data/Gen/BaseCourt.swift new file mode 100644 index 0000000..e799249 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseCourt.swift @@ -0,0 +1,92 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseCourt: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "courts" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var index: Int = 0 + public var club: String = "" + public var name: String? = nil + public var exitAllowed: Bool = false + public var indoor: Bool = false + + public init( + id: String = Store.randomId(), + index: Int = 0, + club: String = "", + name: String? = nil, + exitAllowed: Bool = false, + indoor: Bool = false + ) { + super.init() + self.id = id + self.index = index + self.club = club + self.name = name + self.exitAllowed = exitAllowed + self.indoor = indoor + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _index = "index" + case _club = "club" + case _name = "name" + case _exitAllowed = "exitAllowed" + case _indoor = "indoor" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.index = try container.decodeIfPresent(Int.self, forKey: ._index) ?? 0 + self.club = try container.decodeIfPresent(String.self, forKey: ._club) ?? "" + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.exitAllowed = try container.decodeIfPresent(Bool.self, forKey: ._exitAllowed) ?? false + self.indoor = try container.decodeIfPresent(Bool.self, forKey: ._indoor) ?? false + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.index, forKey: ._index) + try container.encode(self.club, forKey: ._club) + try container.encode(self.name, forKey: ._name) + try container.encode(self.exitAllowed, forKey: ._exitAllowed) + try container.encode(self.indoor, forKey: ._indoor) + try super.encode(to: encoder) + } + + func clubValue() -> Club? { + return Store.main.findById(club) + } + + public func copy(from other: any Storable) { + guard let court = other as? BaseCourt else { return } + self.id = court.id + self.index = court.index + self.club = court.club + self.name = court.name + self.exitAllowed = court.exitAllowed + self.indoor = court.indoor + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Club.self, keyPath: \BaseCourt.club), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseCustomUser.swift b/PadelClubData/Data/Gen/BaseCustomUser.swift new file mode 100644 index 0000000..81f7660 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseCustomUser.swift @@ -0,0 +1,205 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseCustomUser: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "users" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [.post] } + + public var id: String = Store.randomId() + public var username: String = "" + public var email: String = "" + public var clubs: [String] = [] + public var umpireCode: String? = nil + public var licenceId: String? = nil + public var firstName: String = "" + public var lastName: String = "" + public var phone: String? = nil + public var country: String? = nil + public var summonsMessageBody: String? = nil + public var summonsMessageSignature: String? = nil + public var summonsAvailablePaymentMethods: String? = nil + public var summonsDisplayFormat: Bool = false + public var summonsDisplayEntryFee: Bool = false + public var summonsUseFullCustomMessage: Bool = false + public var matchFormatsDefaultDuration: [MatchFormat: Int]? = nil + public var bracketMatchFormatPreference: MatchFormat? = nil + public var groupStageMatchFormatPreference: MatchFormat? = nil + public var loserBracketMatchFormatPreference: MatchFormat? = nil + public var loserBracketMode: LoserBracketMode = .automatic + public var deviceId: String? = nil + public var agents: [String] = [] + + public init( + id: String = Store.randomId(), + username: String = "", + email: String = "", + clubs: [String] = [], + umpireCode: String? = nil, + licenceId: String? = nil, + firstName: String = "", + lastName: String = "", + phone: String? = nil, + country: String? = nil, + summonsMessageBody: String? = nil, + summonsMessageSignature: String? = nil, + summonsAvailablePaymentMethods: String? = nil, + summonsDisplayFormat: Bool = false, + summonsDisplayEntryFee: Bool = false, + summonsUseFullCustomMessage: Bool = false, + matchFormatsDefaultDuration: [MatchFormat: Int]? = nil, + bracketMatchFormatPreference: MatchFormat? = nil, + groupStageMatchFormatPreference: MatchFormat? = nil, + loserBracketMatchFormatPreference: MatchFormat? = nil, + loserBracketMode: LoserBracketMode = .automatic, + deviceId: String? = nil, + agents: [String] = [] + ) { + super.init() + self.id = id + self.username = username + self.email = email + self.clubs = clubs + self.umpireCode = umpireCode + self.licenceId = licenceId + self.firstName = firstName + self.lastName = lastName + self.phone = phone + self.country = country + self.summonsMessageBody = summonsMessageBody + self.summonsMessageSignature = summonsMessageSignature + self.summonsAvailablePaymentMethods = summonsAvailablePaymentMethods + self.summonsDisplayFormat = summonsDisplayFormat + self.summonsDisplayEntryFee = summonsDisplayEntryFee + self.summonsUseFullCustomMessage = summonsUseFullCustomMessage + self.matchFormatsDefaultDuration = matchFormatsDefaultDuration + self.bracketMatchFormatPreference = bracketMatchFormatPreference + self.groupStageMatchFormatPreference = groupStageMatchFormatPreference + self.loserBracketMatchFormatPreference = loserBracketMatchFormatPreference + self.loserBracketMode = loserBracketMode + self.deviceId = deviceId + self.agents = agents + } + required public override init() { + super.init() + } + + public 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 _loserBracketMode = "loserBracketMode" + case _deviceId = "deviceId" + case _agents = "agents" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.username = try container.decodeIfPresent(String.self, forKey: ._username) ?? "" + self.email = try container.decodeIfPresent(String.self, forKey: ._email) ?? "" + self.clubs = try container.decodeIfPresent([String].self, forKey: ._clubs) ?? [] + self.umpireCode = try container.decodeIfPresent(String.self, forKey: ._umpireCode) ?? nil + self.licenceId = try container.decodeIfPresent(String.self, forKey: ._licenceId) ?? nil + self.firstName = try container.decodeIfPresent(String.self, forKey: ._firstName) ?? "" + self.lastName = try container.decodeIfPresent(String.self, forKey: ._lastName) ?? "" + self.phone = try container.decodeIfPresent(String.self, forKey: ._phone) ?? nil + self.country = try container.decodeIfPresent(String.self, forKey: ._country) ?? nil + self.summonsMessageBody = try container.decodeIfPresent(String.self, forKey: ._summonsMessageBody) ?? nil + self.summonsMessageSignature = try container.decodeIfPresent(String.self, forKey: ._summonsMessageSignature) ?? nil + self.summonsAvailablePaymentMethods = try container.decodeIfPresent(String.self, forKey: ._summonsAvailablePaymentMethods) ?? nil + self.summonsDisplayFormat = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayFormat) ?? false + self.summonsDisplayEntryFee = try container.decodeIfPresent(Bool.self, forKey: ._summonsDisplayEntryFee) ?? false + self.summonsUseFullCustomMessage = try container.decodeIfPresent(Bool.self, forKey: ._summonsUseFullCustomMessage) ?? false + self.matchFormatsDefaultDuration = try container.decodeIfPresent([MatchFormat: Int].self, forKey: ._matchFormatsDefaultDuration) ?? nil + self.bracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._bracketMatchFormatPreference) ?? nil + self.groupStageMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._groupStageMatchFormatPreference) ?? nil + self.loserBracketMatchFormatPreference = try container.decodeIfPresent(MatchFormat.self, forKey: ._loserBracketMatchFormatPreference) ?? nil + self.loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic + self.deviceId = try container.decodeIfPresent(String.self, forKey: ._deviceId) ?? nil + self.agents = try container.decodeIfPresent([String].self, forKey: ._agents) ?? [] + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.username, forKey: ._username) + try container.encode(self.email, forKey: ._email) + try container.encode(self.clubs, forKey: ._clubs) + try container.encode(self.umpireCode, forKey: ._umpireCode) + try container.encode(self.licenceId, forKey: ._licenceId) + try container.encode(self.firstName, forKey: ._firstName) + try container.encode(self.lastName, forKey: ._lastName) + try container.encode(self.phone, forKey: ._phone) + try container.encode(self.country, forKey: ._country) + try container.encode(self.summonsMessageBody, forKey: ._summonsMessageBody) + try container.encode(self.summonsMessageSignature, forKey: ._summonsMessageSignature) + try container.encode(self.summonsAvailablePaymentMethods, forKey: ._summonsAvailablePaymentMethods) + try container.encode(self.summonsDisplayFormat, forKey: ._summonsDisplayFormat) + try container.encode(self.summonsDisplayEntryFee, forKey: ._summonsDisplayEntryFee) + try container.encode(self.summonsUseFullCustomMessage, forKey: ._summonsUseFullCustomMessage) + try container.encode(self.matchFormatsDefaultDuration, forKey: ._matchFormatsDefaultDuration) + try container.encode(self.bracketMatchFormatPreference, forKey: ._bracketMatchFormatPreference) + try container.encode(self.groupStageMatchFormatPreference, forKey: ._groupStageMatchFormatPreference) + try container.encode(self.loserBracketMatchFormatPreference, forKey: ._loserBracketMatchFormatPreference) + try container.encode(self.loserBracketMode, forKey: ._loserBracketMode) + try container.encode(self.deviceId, forKey: ._deviceId) + try container.encode(self.agents, forKey: ._agents) + try super.encode(to: encoder) + } + + public func copy(from other: any Storable) { + guard let customuser = other as? BaseCustomUser else { return } + self.id = customuser.id + self.username = customuser.username + self.email = customuser.email + self.clubs = customuser.clubs + self.umpireCode = customuser.umpireCode + self.licenceId = customuser.licenceId + self.firstName = customuser.firstName + self.lastName = customuser.lastName + self.phone = customuser.phone + self.country = customuser.country + self.summonsMessageBody = customuser.summonsMessageBody + self.summonsMessageSignature = customuser.summonsMessageSignature + self.summonsAvailablePaymentMethods = customuser.summonsAvailablePaymentMethods + self.summonsDisplayFormat = customuser.summonsDisplayFormat + self.summonsDisplayEntryFee = customuser.summonsDisplayEntryFee + self.summonsUseFullCustomMessage = customuser.summonsUseFullCustomMessage + self.matchFormatsDefaultDuration = customuser.matchFormatsDefaultDuration + self.bracketMatchFormatPreference = customuser.bracketMatchFormatPreference + self.groupStageMatchFormatPreference = customuser.groupStageMatchFormatPreference + self.loserBracketMatchFormatPreference = customuser.loserBracketMatchFormatPreference + self.loserBracketMode = customuser.loserBracketMode + self.deviceId = customuser.deviceId + self.agents = customuser.agents + } + + public static func relationships() -> [Relationship] { + return [] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseDateInterval.swift b/PadelClubData/Data/Gen/BaseDateInterval.swift new file mode 100644 index 0000000..caadaa3 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseDateInterval.swift @@ -0,0 +1,79 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseDateInterval: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "date-intervals" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var event: String = "" + public var courtIndex: Int = 0 + public var startDate: Date = Date() + public var endDate: Date = Date() + + public init( + id: String = Store.randomId(), + event: String = "", + courtIndex: Int = 0, + startDate: Date = Date(), + endDate: Date = Date() + ) { + super.init() + self.id = id + self.event = event + self.courtIndex = courtIndex + self.startDate = startDate + self.endDate = endDate + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _event = "event" + case _courtIndex = "courtIndex" + case _startDate = "startDate" + case _endDate = "endDate" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.event = try container.decodeIfPresent(String.self, forKey: ._event) ?? "" + self.courtIndex = try container.decodeIfPresent(Int.self, forKey: ._courtIndex) ?? 0 + self.startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? Date() + self.endDate = try container.decodeIfPresent(Date.self, forKey: ._endDate) ?? Date() + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.event, forKey: ._event) + try container.encode(self.courtIndex, forKey: ._courtIndex) + try container.encode(self.startDate, forKey: ._startDate) + try container.encode(self.endDate, forKey: ._endDate) + try super.encode(to: encoder) + } + + public func copy(from other: any Storable) { + guard let dateinterval = other as? BaseDateInterval else { return } + self.id = dateinterval.id + self.event = dateinterval.event + self.courtIndex = dateinterval.courtIndex + self.startDate = dateinterval.startDate + self.endDate = dateinterval.endDate + } + + public static func relationships() -> [Relationship] { + return [] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseDrawLog.swift b/PadelClubData/Data/Gen/BaseDrawLog.swift new file mode 100644 index 0000000..2b619b1 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseDrawLog.swift @@ -0,0 +1,99 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseDrawLog: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "draw-logs" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var tournament: String = "" + public var drawDate: Date = Date() + public var drawSeed: Int = 0 + public var drawMatchIndex: Int = 0 + public var drawTeamPosition: TeamPosition = TeamPosition.one + public var drawType: DrawType = DrawType.seed + + public init( + id: String = Store.randomId(), + tournament: String = "", + drawDate: Date = Date(), + drawSeed: Int = 0, + drawMatchIndex: Int = 0, + drawTeamPosition: TeamPosition = TeamPosition.one, + drawType: DrawType = DrawType.seed + ) { + super.init() + self.id = id + self.tournament = tournament + self.drawDate = drawDate + self.drawSeed = drawSeed + self.drawMatchIndex = drawMatchIndex + self.drawTeamPosition = drawTeamPosition + self.drawType = drawType + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _tournament = "tournament" + case _drawDate = "drawDate" + case _drawSeed = "drawSeed" + case _drawMatchIndex = "drawMatchIndex" + case _drawTeamPosition = "drawTeamPosition" + case _drawType = "drawType" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.tournament = try container.decodeIfPresent(String.self, forKey: ._tournament) ?? "" + self.drawDate = try container.decodeIfPresent(Date.self, forKey: ._drawDate) ?? Date() + self.drawSeed = try container.decodeIfPresent(Int.self, forKey: ._drawSeed) ?? 0 + self.drawMatchIndex = try container.decodeIfPresent(Int.self, forKey: ._drawMatchIndex) ?? 0 + self.drawTeamPosition = try container.decodeIfPresent(TeamPosition.self, forKey: ._drawTeamPosition) ?? TeamPosition.one + self.drawType = try container.decodeIfPresent(DrawType.self, forKey: ._drawType) ?? DrawType.seed + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.tournament, forKey: ._tournament) + try container.encode(self.drawDate, forKey: ._drawDate) + try container.encode(self.drawSeed, forKey: ._drawSeed) + try container.encode(self.drawMatchIndex, forKey: ._drawMatchIndex) + try container.encode(self.drawTeamPosition, forKey: ._drawTeamPosition) + try container.encode(self.drawType, forKey: ._drawType) + try super.encode(to: encoder) + } + + func tournamentValue() -> Tournament? { + return Store.main.findById(tournament) + } + + public func copy(from other: any Storable) { + guard let drawlog = other as? BaseDrawLog else { return } + self.id = drawlog.id + self.tournament = drawlog.tournament + self.drawDate = drawlog.drawDate + self.drawSeed = drawlog.drawSeed + self.drawMatchIndex = drawlog.drawMatchIndex + self.drawTeamPosition = drawlog.drawTeamPosition + self.drawType = drawlog.drawType + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Tournament.self, keyPath: \BaseDrawLog.tournament), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseEvent.swift b/PadelClubData/Data/Gen/BaseEvent.swift new file mode 100644 index 0000000..6b7bcc2 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseEvent.swift @@ -0,0 +1,99 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseEvent: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "events" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var creator: String? = nil + public var club: String? = nil + public var creationDate: Date = Date() + public var name: String? = nil + public var tenupId: String? = nil + + public init( + id: String = Store.randomId(), + creator: String? = nil, + club: String? = nil, + creationDate: Date = Date(), + name: String? = nil, + tenupId: String? = nil + ) { + super.init() + self.id = id + self.creator = creator + self.club = club + self.creationDate = creationDate + self.name = name + self.tenupId = tenupId + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _creator = "creator" + case _club = "club" + case _creationDate = "creationDate" + case _name = "name" + case _tenupId = "tenupId" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.creator = try container.decodeIfPresent(String.self, forKey: ._creator) ?? nil + self.club = try container.decodeIfPresent(String.self, forKey: ._club) ?? nil + self.creationDate = try container.decodeIfPresent(Date.self, forKey: ._creationDate) ?? Date() + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.tenupId = try container.decodeIfPresent(String.self, forKey: ._tenupId) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.creator, forKey: ._creator) + try container.encode(self.club, forKey: ._club) + try container.encode(self.creationDate, forKey: ._creationDate) + try container.encode(self.name, forKey: ._name) + try container.encode(self.tenupId, forKey: ._tenupId) + try super.encode(to: encoder) + } + + func creatorValue() -> CustomUser? { + guard let creator = self.creator else { return nil } + return Store.main.findById(creator) + } + + func clubValue() -> Club? { + guard let club = self.club else { return nil } + return Store.main.findById(club) + } + + public func copy(from other: any Storable) { + guard let event = other as? BaseEvent else { return } + self.id = event.id + self.creator = event.creator + self.club = event.club + self.creationDate = event.creationDate + self.name = event.name + self.tenupId = event.tenupId + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: CustomUser.self, keyPath: \BaseEvent.creator), + Relationship(type: Club.self, keyPath: \BaseEvent.club), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseGroupStage.swift b/PadelClubData/Data/Gen/BaseGroupStage.swift new file mode 100644 index 0000000..a4f11cb --- /dev/null +++ b/PadelClubData/Data/Gen/BaseGroupStage.swift @@ -0,0 +1,106 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseGroupStage: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "group-stages" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var tournament: String = "" + public var index: Int = 0 + public var size: Int = 0 + public var format: MatchFormat? = nil + public var startDate: Date? = nil + public var name: String? = nil + public var step: Int = 0 + + public init( + id: String = Store.randomId(), + tournament: String = "", + index: Int = 0, + size: Int = 0, + format: MatchFormat? = nil, + startDate: Date? = nil, + name: String? = nil, + step: Int = 0 + ) { + super.init() + self.id = id + self.tournament = tournament + self.index = index + self.size = size + self.format = format + self.startDate = startDate + self.name = name + self.step = step + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _tournament = "tournament" + case _index = "index" + case _size = "size" + case _format = "format" + case _startDate = "startDate" + case _name = "name" + case _step = "step" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.tournament = try container.decodeIfPresent(String.self, forKey: ._tournament) ?? "" + self.index = try container.decodeIfPresent(Int.self, forKey: ._index) ?? 0 + self.size = try container.decodeIfPresent(Int.self, forKey: ._size) ?? 0 + self.format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) ?? nil + self.startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? nil + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.step = try container.decodeIfPresent(Int.self, forKey: ._step) ?? 0 + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.tournament, forKey: ._tournament) + try container.encode(self.index, forKey: ._index) + try container.encode(self.size, forKey: ._size) + try container.encode(self.format, forKey: ._format) + try container.encode(self.startDate, forKey: ._startDate) + try container.encode(self.name, forKey: ._name) + try container.encode(self.step, forKey: ._step) + try super.encode(to: encoder) + } + + func tournamentValue() -> Tournament? { + return Store.main.findById(tournament) + } + + public func copy(from other: any Storable) { + guard let groupstage = other as? BaseGroupStage else { return } + self.id = groupstage.id + self.tournament = groupstage.tournament + self.index = groupstage.index + self.size = groupstage.size + self.format = groupstage.format + self.startDate = groupstage.startDate + self.name = groupstage.name + self.step = groupstage.step + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Tournament.self, keyPath: \BaseGroupStage.tournament), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseMatch.swift b/PadelClubData/Data/Gen/BaseMatch.swift new file mode 100644 index 0000000..22848f3 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseMatch.swift @@ -0,0 +1,155 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseMatch: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "matches" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var round: String? = nil + public var groupStage: String? = nil + public var startDate: Date? = nil + public var endDate: Date? = nil + public var index: Int = 0 + public var format: MatchFormat? = nil + public var servingTeamId: String? = nil + public var winningTeamId: String? = nil + public var losingTeamId: String? = nil + public var name: String? = nil + public var disabled: Bool = false + public var courtIndex: Int? = nil + public var confirmed: Bool = false + + public init( + id: String = Store.randomId(), + round: String? = nil, + groupStage: String? = nil, + startDate: Date? = nil, + endDate: Date? = nil, + index: Int = 0, + format: MatchFormat? = nil, + servingTeamId: String? = nil, + winningTeamId: String? = nil, + losingTeamId: String? = nil, + name: String? = nil, + disabled: Bool = false, + courtIndex: Int? = nil, + confirmed: Bool = false + ) { + super.init() + self.id = id + self.round = round + self.groupStage = groupStage + self.startDate = startDate + self.endDate = endDate + self.index = index + self.format = format + self.servingTeamId = servingTeamId + self.winningTeamId = winningTeamId + self.losingTeamId = losingTeamId + self.name = name + self.disabled = disabled + self.courtIndex = courtIndex + self.confirmed = confirmed + } + required public override init() { + super.init() + } + + public 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 _servingTeamId = "servingTeamId" + case _winningTeamId = "winningTeamId" + case _losingTeamId = "losingTeamId" + case _name = "name" + case _disabled = "disabled" + case _courtIndex = "courtIndex" + case _confirmed = "confirmed" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.round = try container.decodeIfPresent(String.self, forKey: ._round) ?? nil + self.groupStage = try container.decodeIfPresent(String.self, forKey: ._groupStage) ?? nil + self.startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? nil + self.endDate = try container.decodeIfPresent(Date.self, forKey: ._endDate) ?? nil + self.index = try container.decodeIfPresent(Int.self, forKey: ._index) ?? 0 + self.format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) ?? nil + self.servingTeamId = try container.decodeIfPresent(String.self, forKey: ._servingTeamId) ?? nil + self.winningTeamId = try container.decodeIfPresent(String.self, forKey: ._winningTeamId) ?? nil + self.losingTeamId = try container.decodeIfPresent(String.self, forKey: ._losingTeamId) ?? nil + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.disabled = try container.decodeIfPresent(Bool.self, forKey: ._disabled) ?? false + self.courtIndex = try container.decodeIfPresent(Int.self, forKey: ._courtIndex) ?? nil + self.confirmed = try container.decodeIfPresent(Bool.self, forKey: ._confirmed) ?? false + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.round, forKey: ._round) + try container.encode(self.groupStage, forKey: ._groupStage) + try container.encode(self.startDate, forKey: ._startDate) + try container.encode(self.endDate, forKey: ._endDate) + try container.encode(self.index, forKey: ._index) + try container.encode(self.format, forKey: ._format) + try container.encode(self.servingTeamId, forKey: ._servingTeamId) + try container.encode(self.winningTeamId, forKey: ._winningTeamId) + try container.encode(self.losingTeamId, forKey: ._losingTeamId) + try container.encode(self.name, forKey: ._name) + try container.encode(self.disabled, forKey: ._disabled) + try container.encode(self.courtIndex, forKey: ._courtIndex) + try container.encode(self.confirmed, forKey: ._confirmed) + try super.encode(to: encoder) + } + + func roundValue() -> Round? { + guard let round = self.round else { return nil } + return self.store?.findById(round) + } + + func groupStageValue() -> GroupStage? { + guard let groupStage = self.groupStage else { return nil } + return self.store?.findById(groupStage) + } + + public func copy(from other: any Storable) { + guard let match = other as? BaseMatch else { return } + self.id = match.id + self.round = match.round + self.groupStage = match.groupStage + self.startDate = match.startDate + self.endDate = match.endDate + self.index = match.index + self.format = match.format + self.servingTeamId = match.servingTeamId + self.winningTeamId = match.winningTeamId + self.losingTeamId = match.losingTeamId + self.name = match.name + self.disabled = match.disabled + self.courtIndex = match.courtIndex + self.confirmed = match.confirmed + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Round.self, keyPath: \BaseMatch.round), + Relationship(type: GroupStage.self, keyPath: \BaseMatch.groupStage), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseMatchScheduler.swift b/PadelClubData/Data/Gen/BaseMatchScheduler.swift new file mode 100644 index 0000000..e0af3c6 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseMatchScheduler.swift @@ -0,0 +1,162 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseMatchScheduler: BaseModelObject, Storable { + + public static func resourceName() -> String { return "match-scheduler" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var tournament: String = "" + public var timeDifferenceLimit: Int = 5 + public var loserBracketRotationDifference: Int = 0 + public var upperBracketRotationDifference: Int = 1 + public var accountUpperBracketBreakTime: Bool = true + public var accountLoserBracketBreakTime: Bool = false + public var randomizeCourts: Bool = true + public var rotationDifferenceIsImportant: Bool = false + public var shouldHandleUpperRoundSlice: Bool = false + public var shouldEndRoundBeforeStartingNext: Bool = true + public var groupStageChunkCount: Int? = nil + public var overrideCourtsUnavailability: Bool = false + public var shouldTryToFillUpCourtsAvailable: Bool = false + public var courtsAvailable: Set = Set() + public var simultaneousStart: Bool = true + + public init( + id: String = Store.randomId(), + tournament: String = "", + timeDifferenceLimit: Int = 5, + loserBracketRotationDifference: Int = 0, + upperBracketRotationDifference: Int = 1, + accountUpperBracketBreakTime: Bool = true, + accountLoserBracketBreakTime: Bool = false, + randomizeCourts: Bool = true, + rotationDifferenceIsImportant: Bool = false, + shouldHandleUpperRoundSlice: Bool = false, + shouldEndRoundBeforeStartingNext: Bool = true, + groupStageChunkCount: Int? = nil, + overrideCourtsUnavailability: Bool = false, + shouldTryToFillUpCourtsAvailable: Bool = false, + courtsAvailable: Set = Set(), + simultaneousStart: Bool = true + ) { + super.init() + self.id = id + self.tournament = tournament + self.timeDifferenceLimit = timeDifferenceLimit + self.loserBracketRotationDifference = loserBracketRotationDifference + self.upperBracketRotationDifference = upperBracketRotationDifference + self.accountUpperBracketBreakTime = accountUpperBracketBreakTime + self.accountLoserBracketBreakTime = accountLoserBracketBreakTime + self.randomizeCourts = randomizeCourts + self.rotationDifferenceIsImportant = rotationDifferenceIsImportant + self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice + self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext + self.groupStageChunkCount = groupStageChunkCount + self.overrideCourtsUnavailability = overrideCourtsUnavailability + self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable + self.courtsAvailable = courtsAvailable + self.simultaneousStart = simultaneousStart + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _tournament = "tournament" + case _timeDifferenceLimit = "timeDifferenceLimit" + case _loserBracketRotationDifference = "loserBracketRotationDifference" + case _upperBracketRotationDifference = "upperBracketRotationDifference" + case _accountUpperBracketBreakTime = "accountUpperBracketBreakTime" + case _accountLoserBracketBreakTime = "accountLoserBracketBreakTime" + case _randomizeCourts = "randomizeCourts" + case _rotationDifferenceIsImportant = "rotationDifferenceIsImportant" + case _shouldHandleUpperRoundSlice = "shouldHandleUpperRoundSlice" + case _shouldEndRoundBeforeStartingNext = "shouldEndRoundBeforeStartingNext" + case _groupStageChunkCount = "groupStageChunkCount" + case _overrideCourtsUnavailability = "overrideCourtsUnavailability" + case _shouldTryToFillUpCourtsAvailable = "shouldTryToFillUpCourtsAvailable" + case _courtsAvailable = "courtsAvailable" + case _simultaneousStart = "simultaneousStart" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.tournament = try container.decodeIfPresent(String.self, forKey: ._tournament) ?? "" + self.timeDifferenceLimit = try container.decodeIfPresent(Int.self, forKey: ._timeDifferenceLimit) ?? 5 + self.loserBracketRotationDifference = try container.decodeIfPresent(Int.self, forKey: ._loserBracketRotationDifference) ?? 0 + self.upperBracketRotationDifference = try container.decodeIfPresent(Int.self, forKey: ._upperBracketRotationDifference) ?? 1 + self.accountUpperBracketBreakTime = try container.decodeIfPresent(Bool.self, forKey: ._accountUpperBracketBreakTime) ?? true + self.accountLoserBracketBreakTime = try container.decodeIfPresent(Bool.self, forKey: ._accountLoserBracketBreakTime) ?? false + self.randomizeCourts = try container.decodeIfPresent(Bool.self, forKey: ._randomizeCourts) ?? true + self.rotationDifferenceIsImportant = try container.decodeIfPresent(Bool.self, forKey: ._rotationDifferenceIsImportant) ?? false + self.shouldHandleUpperRoundSlice = try container.decodeIfPresent(Bool.self, forKey: ._shouldHandleUpperRoundSlice) ?? false + self.shouldEndRoundBeforeStartingNext = try container.decodeIfPresent(Bool.self, forKey: ._shouldEndRoundBeforeStartingNext) ?? true + self.groupStageChunkCount = try container.decodeIfPresent(Int.self, forKey: ._groupStageChunkCount) ?? nil + self.overrideCourtsUnavailability = try container.decodeIfPresent(Bool.self, forKey: ._overrideCourtsUnavailability) ?? false + self.shouldTryToFillUpCourtsAvailable = try container.decodeIfPresent(Bool.self, forKey: ._shouldTryToFillUpCourtsAvailable) ?? false + self.courtsAvailable = try container.decodeIfPresent(Set.self, forKey: ._courtsAvailable) ?? Set() + self.simultaneousStart = try container.decodeIfPresent(Bool.self, forKey: ._simultaneousStart) ?? true + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.tournament, forKey: ._tournament) + try container.encode(self.timeDifferenceLimit, forKey: ._timeDifferenceLimit) + try container.encode(self.loserBracketRotationDifference, forKey: ._loserBracketRotationDifference) + try container.encode(self.upperBracketRotationDifference, forKey: ._upperBracketRotationDifference) + try container.encode(self.accountUpperBracketBreakTime, forKey: ._accountUpperBracketBreakTime) + try container.encode(self.accountLoserBracketBreakTime, forKey: ._accountLoserBracketBreakTime) + try container.encode(self.randomizeCourts, forKey: ._randomizeCourts) + try container.encode(self.rotationDifferenceIsImportant, forKey: ._rotationDifferenceIsImportant) + try container.encode(self.shouldHandleUpperRoundSlice, forKey: ._shouldHandleUpperRoundSlice) + try container.encode(self.shouldEndRoundBeforeStartingNext, forKey: ._shouldEndRoundBeforeStartingNext) + try container.encode(self.groupStageChunkCount, forKey: ._groupStageChunkCount) + try container.encode(self.overrideCourtsUnavailability, forKey: ._overrideCourtsUnavailability) + try container.encode(self.shouldTryToFillUpCourtsAvailable, forKey: ._shouldTryToFillUpCourtsAvailable) + try container.encode(self.courtsAvailable, forKey: ._courtsAvailable) + try container.encode(self.simultaneousStart, forKey: ._simultaneousStart) + try super.encode(to: encoder) + } + + func tournamentValue() -> Tournament? { + return Store.main.findById(tournament) + } + + public func copy(from other: any Storable) { + guard let matchscheduler = other as? BaseMatchScheduler else { return } + self.id = matchscheduler.id + self.tournament = matchscheduler.tournament + self.timeDifferenceLimit = matchscheduler.timeDifferenceLimit + self.loserBracketRotationDifference = matchscheduler.loserBracketRotationDifference + self.upperBracketRotationDifference = matchscheduler.upperBracketRotationDifference + self.accountUpperBracketBreakTime = matchscheduler.accountUpperBracketBreakTime + self.accountLoserBracketBreakTime = matchscheduler.accountLoserBracketBreakTime + self.randomizeCourts = matchscheduler.randomizeCourts + self.rotationDifferenceIsImportant = matchscheduler.rotationDifferenceIsImportant + self.shouldHandleUpperRoundSlice = matchscheduler.shouldHandleUpperRoundSlice + self.shouldEndRoundBeforeStartingNext = matchscheduler.shouldEndRoundBeforeStartingNext + self.groupStageChunkCount = matchscheduler.groupStageChunkCount + self.overrideCourtsUnavailability = matchscheduler.overrideCourtsUnavailability + self.shouldTryToFillUpCourtsAvailable = matchscheduler.shouldTryToFillUpCourtsAvailable + self.courtsAvailable = matchscheduler.courtsAvailable + self.simultaneousStart = matchscheduler.simultaneousStart + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Tournament.self, keyPath: \BaseMatchScheduler.tournament), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseMonthData.swift b/PadelClubData/Data/Gen/BaseMonthData.swift new file mode 100644 index 0000000..38f7451 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseMonthData.swift @@ -0,0 +1,121 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseMonthData: BaseModelObject, Storable { + + public static func resourceName() -> String { return "month-data" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var monthKey: String = "" + public var creationDate: Date = Date() + public var maleUnrankedValue: Int? = nil + public var femaleUnrankedValue: Int? = nil + public var maleCount: Int? = nil + public var femaleCount: Int? = nil + public var anonymousCount: Int? = nil + public var incompleteMode: Bool = false + public var dataModelIdentifier: String? = nil + public var fileModelIdentifier: String? = nil + + public init( + id: String = Store.randomId(), + monthKey: String = "", + creationDate: Date = Date(), + maleUnrankedValue: Int? = nil, + femaleUnrankedValue: Int? = nil, + maleCount: Int? = nil, + femaleCount: Int? = nil, + anonymousCount: Int? = nil, + incompleteMode: Bool = false, + dataModelIdentifier: String? = nil, + fileModelIdentifier: String? = nil + ) { + super.init() + self.id = id + self.monthKey = monthKey + self.creationDate = creationDate + self.maleUnrankedValue = maleUnrankedValue + self.femaleUnrankedValue = femaleUnrankedValue + self.maleCount = maleCount + self.femaleCount = femaleCount + self.anonymousCount = anonymousCount + self.incompleteMode = incompleteMode + self.dataModelIdentifier = dataModelIdentifier + self.fileModelIdentifier = fileModelIdentifier + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _monthKey = "monthKey" + case _creationDate = "creationDate" + case _maleUnrankedValue = "maleUnrankedValue" + case _femaleUnrankedValue = "femaleUnrankedValue" + case _maleCount = "maleCount" + case _femaleCount = "femaleCount" + case _anonymousCount = "anonymousCount" + case _incompleteMode = "incompleteMode" + case _dataModelIdentifier = "dataModelIdentifier" + case _fileModelIdentifier = "fileModelIdentifier" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.monthKey = try container.decodeIfPresent(String.self, forKey: ._monthKey) ?? "" + self.creationDate = try container.decodeIfPresent(Date.self, forKey: ._creationDate) ?? Date() + self.maleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._maleUnrankedValue) ?? nil + self.femaleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._femaleUnrankedValue) ?? nil + self.maleCount = try container.decodeIfPresent(Int.self, forKey: ._maleCount) ?? nil + self.femaleCount = try container.decodeIfPresent(Int.self, forKey: ._femaleCount) ?? nil + self.anonymousCount = try container.decodeIfPresent(Int.self, forKey: ._anonymousCount) ?? nil + self.incompleteMode = try container.decodeIfPresent(Bool.self, forKey: ._incompleteMode) ?? false + self.dataModelIdentifier = try container.decodeIfPresent(String.self, forKey: ._dataModelIdentifier) ?? nil + self.fileModelIdentifier = try container.decodeIfPresent(String.self, forKey: ._fileModelIdentifier) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.monthKey, forKey: ._monthKey) + try container.encode(self.creationDate, forKey: ._creationDate) + try container.encode(self.maleUnrankedValue, forKey: ._maleUnrankedValue) + try container.encode(self.femaleUnrankedValue, forKey: ._femaleUnrankedValue) + try container.encode(self.maleCount, forKey: ._maleCount) + try container.encode(self.femaleCount, forKey: ._femaleCount) + try container.encode(self.anonymousCount, forKey: ._anonymousCount) + try container.encode(self.incompleteMode, forKey: ._incompleteMode) + try container.encode(self.dataModelIdentifier, forKey: ._dataModelIdentifier) + try container.encode(self.fileModelIdentifier, forKey: ._fileModelIdentifier) + try super.encode(to: encoder) + } + + public func copy(from other: any Storable) { + guard let monthdata = other as? BaseMonthData else { return } + self.id = monthdata.id + self.monthKey = monthdata.monthKey + self.creationDate = monthdata.creationDate + self.maleUnrankedValue = monthdata.maleUnrankedValue + self.femaleUnrankedValue = monthdata.femaleUnrankedValue + self.maleCount = monthdata.maleCount + self.femaleCount = monthdata.femaleCount + self.anonymousCount = monthdata.anonymousCount + self.incompleteMode = monthdata.incompleteMode + self.dataModelIdentifier = monthdata.dataModelIdentifier + self.fileModelIdentifier = monthdata.fileModelIdentifier + } + + public static func relationships() -> [Relationship] { + return [] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BasePlayerRegistration.swift b/PadelClubData/Data/Gen/BasePlayerRegistration.swift new file mode 100644 index 0000000..00cd033 --- /dev/null +++ b/PadelClubData/Data/Gen/BasePlayerRegistration.swift @@ -0,0 +1,205 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BasePlayerRegistration: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "player-registrations" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var teamRegistration: String? = nil + public var firstName: String = "" + public var lastName: String = "" + public var licenceId: String? = nil + public var rank: Int? = nil + public var paymentType: PlayerPaymentType? = nil + public var sex: PlayerSexType? = nil + public var tournamentPlayed: Int? = nil + public var points: Double? = nil + public var clubName: String? = nil + public var ligueName: String? = nil + public var assimilation: String? = nil + public var phoneNumber: String? = nil + public var email: String? = nil + public var birthdate: String? = nil + public var computedRank: Int = 0 + public var source: PlayerRegistration.PlayerDataSource? = nil + public var hasArrived: Bool = false + public var coach: Bool = false + public var captain: Bool = false + public var registeredOnline: Bool = false + + public init( + id: String = Store.randomId(), + 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: PlayerRegistration.PlayerDataSource? = nil, + hasArrived: Bool = false, + coach: Bool = false, + captain: Bool = false, + registeredOnline: Bool = false + ) { + super.init() + self.id = id + self.teamRegistration = teamRegistration + self.firstName = firstName + self.lastName = lastName + self.licenceId = licenceId + self.rank = rank + self.paymentType = paymentType + self.sex = sex + self.tournamentPlayed = tournamentPlayed + self.points = points + self.clubName = clubName + self.ligueName = ligueName + self.assimilation = assimilation + self.phoneNumber = phoneNumber + self.email = email + self.birthdate = birthdate + self.computedRank = computedRank + self.source = source + self.hasArrived = hasArrived + self.coach = coach + self.captain = captain + self.registeredOnline = registeredOnline + } + required public override init() { + super.init() + } + + public 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 _phoneNumber = "phoneNumber" + case _email = "email" + case _birthdate = "birthdate" + case _computedRank = "computedRank" + case _source = "source" + case _hasArrived = "hasArrived" + case _coach = "coach" + case _captain = "captain" + case _registeredOnline = "registeredOnline" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.teamRegistration = try container.decodeIfPresent(String.self, forKey: ._teamRegistration) ?? nil + self.firstName = try container.decodeIfPresent(String.self, forKey: ._firstName) ?? "" + self.lastName = try container.decodeIfPresent(String.self, forKey: ._lastName) ?? "" + self.licenceId = try container.decodeIfPresent(String.self, forKey: ._licenceId) ?? nil + self.rank = try container.decodeIfPresent(Int.self, forKey: ._rank) ?? nil + self.paymentType = try container.decodeIfPresent(PlayerPaymentType.self, forKey: ._paymentType) ?? nil + self.sex = try container.decodeIfPresent(PlayerSexType.self, forKey: ._sex) ?? nil + self.tournamentPlayed = try container.decodeIfPresent(Int.self, forKey: ._tournamentPlayed) ?? nil + self.points = try container.decodeIfPresent(Double.self, forKey: ._points) ?? nil + self.clubName = try container.decodeIfPresent(String.self, forKey: ._clubName) ?? nil + self.ligueName = try container.decodeIfPresent(String.self, forKey: ._ligueName) ?? nil + self.assimilation = try container.decodeIfPresent(String.self, forKey: ._assimilation) ?? nil + self.phoneNumber = try container.decodeIfPresent(String.self, forKey: ._phoneNumber) ?? nil + self.email = try container.decodeIfPresent(String.self, forKey: ._email) ?? nil + self.birthdate = try container.decodeIfPresent(String.self, forKey: ._birthdate) ?? nil + self.computedRank = try container.decodeIfPresent(Int.self, forKey: ._computedRank) ?? 0 + self.source = try container.decodeIfPresent(PlayerRegistration.PlayerDataSource.self, forKey: ._source) ?? nil + self.hasArrived = try container.decodeIfPresent(Bool.self, forKey: ._hasArrived) ?? false + self.coach = try container.decodeIfPresent(Bool.self, forKey: ._coach) ?? false + self.captain = try container.decodeIfPresent(Bool.self, forKey: ._captain) ?? false + self.registeredOnline = try container.decodeIfPresent(Bool.self, forKey: ._registeredOnline) ?? false + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.teamRegistration, forKey: ._teamRegistration) + try container.encode(self.firstName, forKey: ._firstName) + try container.encode(self.lastName, forKey: ._lastName) + try container.encode(self.licenceId, forKey: ._licenceId) + try container.encode(self.rank, forKey: ._rank) + try container.encode(self.paymentType, forKey: ._paymentType) + try container.encode(self.sex, forKey: ._sex) + try container.encode(self.tournamentPlayed, forKey: ._tournamentPlayed) + try container.encode(self.points, forKey: ._points) + try container.encode(self.clubName, forKey: ._clubName) + try container.encode(self.ligueName, forKey: ._ligueName) + try container.encode(self.assimilation, forKey: ._assimilation) + try container.encode(self.phoneNumber, forKey: ._phoneNumber) + try container.encode(self.email, forKey: ._email) + try container.encode(self.birthdate, forKey: ._birthdate) + try container.encode(self.computedRank, forKey: ._computedRank) + try container.encode(self.source, forKey: ._source) + try container.encode(self.hasArrived, forKey: ._hasArrived) + try container.encode(self.coach, forKey: ._coach) + try container.encode(self.captain, forKey: ._captain) + try container.encode(self.registeredOnline, forKey: ._registeredOnline) + try super.encode(to: encoder) + } + + func teamRegistrationValue() -> TeamRegistration? { + guard let teamRegistration = self.teamRegistration else { return nil } + return Store.main.findById(teamRegistration) + } + + public func copy(from other: any Storable) { + guard let playerregistration = other as? BasePlayerRegistration else { return } + self.id = playerregistration.id + self.teamRegistration = playerregistration.teamRegistration + self.firstName = playerregistration.firstName + self.lastName = playerregistration.lastName + self.licenceId = playerregistration.licenceId + self.rank = playerregistration.rank + self.paymentType = playerregistration.paymentType + self.sex = playerregistration.sex + self.tournamentPlayed = playerregistration.tournamentPlayed + self.points = playerregistration.points + self.clubName = playerregistration.clubName + self.ligueName = playerregistration.ligueName + self.assimilation = playerregistration.assimilation + self.phoneNumber = playerregistration.phoneNumber + self.email = playerregistration.email + self.birthdate = playerregistration.birthdate + self.computedRank = playerregistration.computedRank + self.source = playerregistration.source + self.hasArrived = playerregistration.hasArrived + self.coach = playerregistration.coach + self.captain = playerregistration.captain + self.registeredOnline = playerregistration.registeredOnline + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: TeamRegistration.self, keyPath: \BasePlayerRegistration.teamRegistration), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BasePurchase.swift b/PadelClubData/Data/Gen/BasePurchase.swift new file mode 100644 index 0000000..cd4acf3 --- /dev/null +++ b/PadelClubData/Data/Gen/BasePurchase.swift @@ -0,0 +1,98 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage + +public class BasePurchase: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "purchases" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: UInt64 = 0 + public var user: String = "" + public var purchaseDate: Date = Date() + public var productId: String = "" + public var quantity: Int? = nil + public var revocationDate: Date? = nil + public var expirationDate: Date? = nil + + public init( + id: UInt64 = 0, + user: String = "", + purchaseDate: Date = Date(), + productId: String = "", + quantity: Int? = nil, + revocationDate: Date? = nil, + expirationDate: Date? = nil + ) { + super.init() + self.id = id + self.user = user + self.purchaseDate = purchaseDate + self.productId = productId + self.quantity = quantity + self.revocationDate = revocationDate + self.expirationDate = expirationDate + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case id = "id" + case user = "user" + case purchaseDate = "purchaseDate" + case productId = "productId" + case quantity = "quantity" + case revocationDate = "revocationDate" + case expirationDate = "expirationDate" + } + + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UInt64.self, forKey: .id) ?? 0 + self.user = try container.decodeEncrypted(key: .user) + self.purchaseDate = try container.decodeIfPresent(Date.self, forKey: .purchaseDate) ?? Date() + self.productId = try container.decodeIfPresent(String.self, forKey: .productId) ?? "" + self.quantity = try container.decodeIfPresent(Int.self, forKey: .quantity) ?? nil + self.revocationDate = try container.decodeIfPresent(Date.self, forKey: .revocationDate) ?? nil + self.expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encodeAndEncryptIfPresent(self.user.data(using: .utf8), forKey: .user) + try container.encode(self.purchaseDate, forKey: .purchaseDate) + try container.encode(self.productId, forKey: .productId) + try container.encode(self.quantity, forKey: .quantity) + try container.encode(self.revocationDate, forKey: .revocationDate) + try container.encode(self.expirationDate, forKey: .expirationDate) + try super.encode(to: encoder) + } + + func userValue() -> CustomUser? { + return Store.main.findById(user) + } + + public func copy(from other: any Storable) { + guard let purchase = other as? BasePurchase else { return } + self.id = purchase.id + self.user = purchase.user + self.purchaseDate = purchase.purchaseDate + self.productId = purchase.productId + self.quantity = purchase.quantity + self.revocationDate = purchase.revocationDate + self.expirationDate = purchase.expirationDate + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: CustomUser.self, keyPath: \BasePurchase.user), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseRound.swift b/PadelClubData/Data/Gen/BaseRound.swift new file mode 100644 index 0000000..fd35fda --- /dev/null +++ b/PadelClubData/Data/Gen/BaseRound.swift @@ -0,0 +1,106 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseRound: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "rounds" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var tournament: String = "" + public var index: Int = 0 + public var parent: String? = nil + public var format: MatchFormat? = nil + public var startDate: Date? = nil + public var groupStageLoserBracket: Bool = false + public var loserBracketMode: LoserBracketMode = .automatic + + public init( + id: String = Store.randomId(), + tournament: String = "", + index: Int = 0, + parent: String? = nil, + format: MatchFormat? = nil, + startDate: Date? = nil, + groupStageLoserBracket: Bool = false, + loserBracketMode: LoserBracketMode = .automatic + ) { + super.init() + self.id = id + self.tournament = tournament + self.index = index + self.parent = parent + self.format = format + self.startDate = startDate + self.groupStageLoserBracket = groupStageLoserBracket + self.loserBracketMode = loserBracketMode + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _tournament = "tournament" + case _index = "index" + case _parent = "parent" + case _format = "format" + case _startDate = "startDate" + case _groupStageLoserBracket = "groupStageLoserBracket" + case _loserBracketMode = "loserBracketMode" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.tournament = try container.decodeIfPresent(String.self, forKey: ._tournament) ?? "" + self.index = try container.decodeIfPresent(Int.self, forKey: ._index) ?? 0 + self.parent = try container.decodeIfPresent(String.self, forKey: ._parent) ?? nil + self.format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) ?? nil + self.startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? nil + self.groupStageLoserBracket = try container.decodeIfPresent(Bool.self, forKey: ._groupStageLoserBracket) ?? false + self.loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.tournament, forKey: ._tournament) + try container.encode(self.index, forKey: ._index) + try container.encode(self.parent, forKey: ._parent) + try container.encode(self.format, forKey: ._format) + try container.encode(self.startDate, forKey: ._startDate) + try container.encode(self.groupStageLoserBracket, forKey: ._groupStageLoserBracket) + try container.encode(self.loserBracketMode, forKey: ._loserBracketMode) + try super.encode(to: encoder) + } + + func tournamentValue() -> Tournament? { + return Store.main.findById(tournament) + } + + public func copy(from other: any Storable) { + guard let round = other as? BaseRound else { return } + self.id = round.id + self.tournament = round.tournament + self.index = round.index + self.parent = round.parent + self.format = round.format + self.startDate = round.startDate + self.groupStageLoserBracket = round.groupStageLoserBracket + self.loserBracketMode = round.loserBracketMode + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Tournament.self, keyPath: \BaseRound.tournament), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseTeamRegistration.swift b/PadelClubData/Data/Gen/BaseTeamRegistration.swift new file mode 100644 index 0000000..7517b1a --- /dev/null +++ b/PadelClubData/Data/Gen/BaseTeamRegistration.swift @@ -0,0 +1,198 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseTeamRegistration: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "team-registrations" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var tournament: String = "" + public var groupStage: String? = nil + public var registrationDate: Date? = nil + public var callDate: Date? = nil + public var bracketPosition: Int? = nil + public var groupStagePosition: Int? = nil + public var comment: String? = nil + public var source: String? = nil + public var sourceValue: String? = nil + public var logo: String? = nil + public var name: String? = nil + public var walkOut: Bool = false + public var wildCardBracket: Bool = false + public var wildCardGroupStage: Bool = false + public var weight: Int = 0 + public var lockedWeight: Int? = nil + public var confirmationDate: Date? = nil + public var qualified: Bool = false + public var finalRanking: Int? = nil + public var pointsEarned: Int? = nil + + public init( + id: String = Store.randomId(), + tournament: String = "", + groupStage: String? = nil, + registrationDate: Date? = nil, + callDate: Date? = nil, + bracketPosition: Int? = nil, + groupStagePosition: Int? = nil, + comment: String? = nil, + source: String? = nil, + sourceValue: String? = nil, + logo: String? = nil, + name: String? = nil, + walkOut: Bool = false, + wildCardBracket: Bool = false, + wildCardGroupStage: Bool = false, + weight: Int = 0, + lockedWeight: Int? = nil, + confirmationDate: Date? = nil, + qualified: Bool = false, + finalRanking: Int? = nil, + pointsEarned: Int? = nil + ) { + super.init() + self.id = id + 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 + self.finalRanking = finalRanking + self.pointsEarned = pointsEarned + } + required public override init() { + super.init() + } + + public 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 _walkOut = "walkOut" + case _wildCardBracket = "wildCardBracket" + case _wildCardGroupStage = "wildCardGroupStage" + case _weight = "weight" + case _lockedWeight = "lockedWeight" + case _confirmationDate = "confirmationDate" + case _qualified = "qualified" + case _finalRanking = "finalRanking" + case _pointsEarned = "pointsEarned" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.tournament = try container.decodeIfPresent(String.self, forKey: ._tournament) ?? "" + self.groupStage = try container.decodeIfPresent(String.self, forKey: ._groupStage) ?? nil + self.registrationDate = try container.decodeIfPresent(Date.self, forKey: ._registrationDate) ?? nil + self.callDate = try container.decodeIfPresent(Date.self, forKey: ._callDate) ?? nil + self.bracketPosition = try container.decodeIfPresent(Int.self, forKey: ._bracketPosition) ?? nil + self.groupStagePosition = try container.decodeIfPresent(Int.self, forKey: ._groupStagePosition) ?? nil + self.comment = try container.decodeIfPresent(String.self, forKey: ._comment) ?? nil + self.source = try container.decodeIfPresent(String.self, forKey: ._source) ?? nil + self.sourceValue = try container.decodeIfPresent(String.self, forKey: ._sourceValue) ?? nil + self.logo = try container.decodeIfPresent(String.self, forKey: ._logo) ?? nil + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.walkOut = try container.decodeIfPresent(Bool.self, forKey: ._walkOut) ?? false + self.wildCardBracket = try container.decodeIfPresent(Bool.self, forKey: ._wildCardBracket) ?? false + self.wildCardGroupStage = try container.decodeIfPresent(Bool.self, forKey: ._wildCardGroupStage) ?? false + self.weight = try container.decodeIfPresent(Int.self, forKey: ._weight) ?? 0 + self.lockedWeight = try container.decodeIfPresent(Int.self, forKey: ._lockedWeight) ?? nil + self.confirmationDate = try container.decodeIfPresent(Date.self, forKey: ._confirmationDate) ?? nil + self.qualified = try container.decodeIfPresent(Bool.self, forKey: ._qualified) ?? false + self.finalRanking = try container.decodeIfPresent(Int.self, forKey: ._finalRanking) ?? nil + self.pointsEarned = try container.decodeIfPresent(Int.self, forKey: ._pointsEarned) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.tournament, forKey: ._tournament) + try container.encode(self.groupStage, forKey: ._groupStage) + try container.encode(self.registrationDate, forKey: ._registrationDate) + try container.encode(self.callDate, forKey: ._callDate) + try container.encode(self.bracketPosition, forKey: ._bracketPosition) + try container.encode(self.groupStagePosition, forKey: ._groupStagePosition) + try container.encode(self.comment, forKey: ._comment) + try container.encode(self.source, forKey: ._source) + try container.encode(self.sourceValue, forKey: ._sourceValue) + try container.encode(self.logo, forKey: ._logo) + try container.encode(self.name, forKey: ._name) + try container.encode(self.walkOut, forKey: ._walkOut) + try container.encode(self.wildCardBracket, forKey: ._wildCardBracket) + try container.encode(self.wildCardGroupStage, forKey: ._wildCardGroupStage) + try container.encode(self.weight, forKey: ._weight) + try container.encode(self.lockedWeight, forKey: ._lockedWeight) + try container.encode(self.confirmationDate, forKey: ._confirmationDate) + try container.encode(self.qualified, forKey: ._qualified) + try container.encode(self.finalRanking, forKey: ._finalRanking) + try container.encode(self.pointsEarned, forKey: ._pointsEarned) + try super.encode(to: encoder) + } + + func groupStageValue() -> GroupStage? { + guard let groupStage = self.groupStage else { return nil } + return self.store?.findById(groupStage) + } + + public func copy(from other: any Storable) { + guard let teamregistration = other as? BaseTeamRegistration else { return } + self.id = teamregistration.id + self.tournament = teamregistration.tournament + self.groupStage = teamregistration.groupStage + self.registrationDate = teamregistration.registrationDate + self.callDate = teamregistration.callDate + self.bracketPosition = teamregistration.bracketPosition + self.groupStagePosition = teamregistration.groupStagePosition + self.comment = teamregistration.comment + self.source = teamregistration.source + self.sourceValue = teamregistration.sourceValue + self.logo = teamregistration.logo + self.name = teamregistration.name + self.walkOut = teamregistration.walkOut + self.wildCardBracket = teamregistration.wildCardBracket + self.wildCardGroupStage = teamregistration.wildCardGroupStage + self.weight = teamregistration.weight + self.lockedWeight = teamregistration.lockedWeight + self.confirmationDate = teamregistration.confirmationDate + self.qualified = teamregistration.qualified + self.finalRanking = teamregistration.finalRanking + self.pointsEarned = teamregistration.pointsEarned + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: GroupStage.self, keyPath: \BaseTeamRegistration.groupStage), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseTeamScore.swift b/PadelClubData/Data/Gen/BaseTeamScore.swift new file mode 100644 index 0000000..6b97720 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseTeamScore.swift @@ -0,0 +1,98 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseTeamScore: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "team-scores" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var match: String = "" + public var teamRegistration: String? = nil + public var score: String? = nil + public var walkOut: Int? = nil + public var luckyLoser: Int? = nil + + public init( + id: String = Store.randomId(), + match: String = "", + teamRegistration: String? = nil, + score: String? = nil, + walkOut: Int? = nil, + luckyLoser: Int? = nil + ) { + super.init() + self.id = id + self.match = match + self.teamRegistration = teamRegistration + self.score = score + self.walkOut = walkOut + self.luckyLoser = luckyLoser + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case _id = "id" + case _match = "match" + case _teamRegistration = "teamRegistration" + case _score = "score" + case _walkOut = "walkOut" + case _luckyLoser = "luckyLoser" + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.match = try container.decodeIfPresent(String.self, forKey: ._match) ?? "" + self.teamRegistration = try container.decodeIfPresent(String.self, forKey: ._teamRegistration) ?? nil + self.score = try container.decodeIfPresent(String.self, forKey: ._score) ?? nil + self.walkOut = try container.decodeIfPresent(Int.self, forKey: ._walkOut) ?? nil + self.luckyLoser = try container.decodeIfPresent(Int.self, forKey: ._luckyLoser) ?? nil + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.match, forKey: ._match) + try container.encode(self.teamRegistration, forKey: ._teamRegistration) + try container.encode(self.score, forKey: ._score) + try container.encode(self.walkOut, forKey: ._walkOut) + try container.encode(self.luckyLoser, forKey: ._luckyLoser) + try super.encode(to: encoder) + } + + func matchValue() -> Match? { + return self.store?.findById(match) + } + + func teamRegistrationValue() -> TeamRegistration? { + guard let teamRegistration = self.teamRegistration else { return nil } + return self.store?.findById(teamRegistration) + } + + public func copy(from other: any Storable) { + guard let teamscore = other as? BaseTeamScore else { return } + self.id = teamscore.id + self.match = teamscore.match + self.teamRegistration = teamscore.teamRegistration + self.score = teamscore.score + self.walkOut = teamscore.walkOut + self.luckyLoser = teamscore.luckyLoser + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Match.self, keyPath: \BaseTeamScore.match), + Relationship(type: TeamRegistration.self, keyPath: \BaseTeamScore.teamRegistration), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/BaseTournament.swift b/PadelClubData/Data/Gen/BaseTournament.swift new file mode 100644 index 0000000..4deebf1 --- /dev/null +++ b/PadelClubData/Data/Gen/BaseTournament.swift @@ -0,0 +1,533 @@ +// Generated by SwiftModelGenerator +// Do not modify this file manually + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public class BaseTournament: SyncedModelObject, SyncedStorable { + + public static func resourceName() -> String { return "tournaments" } + public static func tokenExemptedMethods() -> [HTTPMethod] { return [] } + + public var id: String = Store.randomId() + public var event: String? = nil + public var name: String? = nil + public var startDate: Date = Date() + public var endDate: Date? = nil + public var creationDate: Date = Date() + public var isPrivate: Bool = false + public var groupStageFormat: MatchFormat? = nil + public var roundFormat: MatchFormat? = nil + public var loserRoundFormat: MatchFormat? = nil + public var groupStageSortMode: GroupStageOrderingMode = GroupStageOrderingMode.snake + public var groupStageCount: Int = 0 + public var rankSourceDate: Date? = nil + public var dayDuration: Int = 0 + public var teamCount: Int = 0 + public var teamSorting: TeamSortingType = TeamSortingType.inscriptionDate + public var federalCategory: TournamentCategory = TournamentCategory.men + public var federalLevelCategory: TournamentLevel = TournamentLevel.unlisted + public var federalAgeCategory: FederalTournamentAge = FederalTournamentAge.unlisted + public var closedRegistrationDate: Date? = nil + public var groupStageAdditionalQualified: Int = 0 + public var courtCount: Int = 2 + public var prioritizeClubMembers: Bool = false + public var qualifiedPerGroupStage: Int = 0 + public var teamsPerGroupStage: Int = 0 + public var entryFee: Double? = nil + public var payment: TournamentPayment? = nil + public var additionalEstimationDuration: Int = 0 + public var isDeleted: Bool = false + public var isCanceled: Bool = false + public var publishTeams: Bool = false + public var publishSummons: Bool = false + public var publishGroupStages: Bool = false + public var publishBrackets: Bool = false + public var shouldVerifyGroupStage: Bool = false + public var shouldVerifyBracket: Bool = false + public var hideTeamsWeight: Bool = false + public var publishTournament: Bool = false + public var hidePointsEarned: Bool = false + public var publishRankings: Bool = false + public var loserBracketMode: LoserBracketMode = .automatic + public var initialSeedRound: Int = 0 + public var initialSeedCount: Int = 0 + public var enableOnlineRegistration: Bool = false + public var registrationDateLimit: Date? = nil + public var openingRegistrationDate: Date? = nil + public var waitingListLimit: Int? = nil + public var accountIsRequired: Bool = true + public var licenseIsRequired: Bool = true + public var minimumPlayerPerTeam: Int = 2 + public var maximumPlayerPerTeam: Int = 2 + public var information: String? = nil + public var umpireCustomMail: String? = nil + public var umpireCustomContact: String? = nil + public var umpireCustomPhone: String? = nil + public var hideUmpireMail: Bool = false + public var hideUmpirePhone: Bool = true + public var disableRankingFederalRuling: Bool = false + public var teamCountLimit: Bool = true + + public init( + id: String = Store.randomId(), + event: String? = nil, + name: String? = nil, + startDate: Date = Date(), + endDate: Date? = nil, + creationDate: Date = Date(), + isPrivate: Bool = false, + groupStageFormat: MatchFormat? = nil, + roundFormat: MatchFormat? = nil, + loserRoundFormat: MatchFormat? = nil, + groupStageSortMode: GroupStageOrderingMode = GroupStageOrderingMode.snake, + groupStageCount: Int = 0, + rankSourceDate: Date? = nil, + dayDuration: Int = 0, + teamCount: Int = 0, + teamSorting: TeamSortingType = TeamSortingType.inscriptionDate, + federalCategory: TournamentCategory = TournamentCategory.men, + federalLevelCategory: TournamentLevel = TournamentLevel.unlisted, + federalAgeCategory: FederalTournamentAge = FederalTournamentAge.unlisted, + closedRegistrationDate: Date? = nil, + groupStageAdditionalQualified: Int = 0, + courtCount: Int = 2, + prioritizeClubMembers: Bool = false, + qualifiedPerGroupStage: Int = 0, + teamsPerGroupStage: Int = 0, + entryFee: Double? = nil, + payment: TournamentPayment? = nil, + additionalEstimationDuration: Int = 0, + isDeleted: Bool = false, + isCanceled: Bool = false, + publishTeams: Bool = false, + publishSummons: Bool = false, + publishGroupStages: Bool = false, + publishBrackets: Bool = false, + shouldVerifyGroupStage: Bool = false, + shouldVerifyBracket: Bool = false, + hideTeamsWeight: Bool = false, + publishTournament: Bool = false, + hidePointsEarned: Bool = false, + publishRankings: Bool = false, + loserBracketMode: LoserBracketMode = .automatic, + initialSeedRound: Int = 0, + initialSeedCount: Int = 0, + enableOnlineRegistration: Bool = false, + registrationDateLimit: Date? = nil, + openingRegistrationDate: Date? = nil, + waitingListLimit: Int? = nil, + accountIsRequired: Bool = true, + licenseIsRequired: Bool = true, + minimumPlayerPerTeam: Int = 2, + maximumPlayerPerTeam: Int = 2, + information: String? = nil, + umpireCustomMail: String? = nil, + umpireCustomContact: String? = nil, + umpireCustomPhone: String? = nil, + hideUmpireMail: Bool = false, + hideUmpirePhone: Bool = true, + disableRankingFederalRuling: Bool = false, + teamCountLimit: Bool = true + ) { + super.init() + self.id = id + self.event = event + self.name = name + self.startDate = startDate + self.endDate = endDate + self.creationDate = creationDate + self.isPrivate = isPrivate + self.groupStageFormat = groupStageFormat + self.roundFormat = roundFormat + self.loserRoundFormat = loserRoundFormat + self.groupStageSortMode = groupStageSortMode + self.groupStageCount = groupStageCount + self.rankSourceDate = rankSourceDate + self.dayDuration = dayDuration + self.teamCount = teamCount + self.teamSorting = teamSorting + self.federalCategory = federalCategory + self.federalLevelCategory = federalLevelCategory + self.federalAgeCategory = federalAgeCategory + self.closedRegistrationDate = closedRegistrationDate + self.groupStageAdditionalQualified = groupStageAdditionalQualified + self.courtCount = courtCount + self.prioritizeClubMembers = prioritizeClubMembers + self.qualifiedPerGroupStage = qualifiedPerGroupStage + self.teamsPerGroupStage = teamsPerGroupStage + self.entryFee = entryFee + self.payment = payment + self.additionalEstimationDuration = additionalEstimationDuration + self.isDeleted = isDeleted + self.isCanceled = isCanceled + self.publishTeams = publishTeams + self.publishSummons = publishSummons + self.publishGroupStages = publishGroupStages + self.publishBrackets = publishBrackets + self.shouldVerifyGroupStage = shouldVerifyGroupStage + self.shouldVerifyBracket = shouldVerifyBracket + self.hideTeamsWeight = hideTeamsWeight + self.publishTournament = publishTournament + self.hidePointsEarned = hidePointsEarned + self.publishRankings = publishRankings + self.loserBracketMode = loserBracketMode + self.initialSeedRound = initialSeedRound + self.initialSeedCount = initialSeedCount + self.enableOnlineRegistration = enableOnlineRegistration + self.registrationDateLimit = registrationDateLimit + self.openingRegistrationDate = openingRegistrationDate + self.waitingListLimit = waitingListLimit + self.accountIsRequired = accountIsRequired + self.licenseIsRequired = licenseIsRequired + self.minimumPlayerPerTeam = minimumPlayerPerTeam + self.maximumPlayerPerTeam = maximumPlayerPerTeam + self.information = information + self.umpireCustomMail = umpireCustomMail + self.umpireCustomContact = umpireCustomContact + self.umpireCustomPhone = umpireCustomPhone + self.hideUmpireMail = hideUmpireMail + self.hideUmpirePhone = hideUmpirePhone + self.disableRankingFederalRuling = disableRankingFederalRuling + self.teamCountLimit = teamCountLimit + } + required public override init() { + super.init() + } + + public enum CodingKeys: String, CodingKey { + case isCanceled = "isCanceled" + case payment = "payment" + case _id = "id" + case _event = "event" + case _name = "name" + case _startDate = "startDate" + case _endDate = "endDate" + case _creationDate = "creationDate" + case _isPrivate = "isPrivate" + case _groupStageFormat = "groupStageFormat" + case _roundFormat = "roundFormat" + case _loserRoundFormat = "loserRoundFormat" + case _groupStageSortMode = "groupStageSortMode" + case _groupStageCount = "groupStageCount" + case _rankSourceDate = "rankSourceDate" + case _dayDuration = "dayDuration" + case _teamCount = "teamCount" + case _teamSorting = "teamSorting" + case _federalCategory = "federalCategory" + case _federalLevelCategory = "federalLevelCategory" + case _federalAgeCategory = "federalAgeCategory" + case _closedRegistrationDate = "closedRegistrationDate" + case _groupStageAdditionalQualified = "groupStageAdditionalQualified" + case _courtCount = "courtCount" + case _prioritizeClubMembers = "prioritizeClubMembers" + case _qualifiedPerGroupStage = "qualifiedPerGroupStage" + case _teamsPerGroupStage = "teamsPerGroupStage" + case _entryFee = "entryFee" + case _payment = "globalId" + case _additionalEstimationDuration = "additionalEstimationDuration" + case _isDeleted = "isDeleted" + case _isCanceled = "localId" + case _publishTeams = "publishTeams" + case _publishSummons = "publishSummons" + case _publishGroupStages = "publishGroupStages" + case _publishBrackets = "publishBrackets" + case _shouldVerifyGroupStage = "shouldVerifyGroupStage" + case _shouldVerifyBracket = "shouldVerifyBracket" + case _hideTeamsWeight = "hideTeamsWeight" + case _publishTournament = "publishTournament" + case _hidePointsEarned = "hidePointsEarned" + case _publishRankings = "publishRankings" + case _loserBracketMode = "loserBracketMode" + case _initialSeedRound = "initialSeedRound" + case _initialSeedCount = "initialSeedCount" + case _enableOnlineRegistration = "enableOnlineRegistration" + case _registrationDateLimit = "registrationDateLimit" + case _openingRegistrationDate = "openingRegistrationDate" + case _waitingListLimit = "waitingListLimit" + case _accountIsRequired = "accountIsRequired" + case _licenseIsRequired = "licenseIsRequired" + case _minimumPlayerPerTeam = "minimumPlayerPerTeam" + case _maximumPlayerPerTeam = "maximumPlayerPerTeam" + case _information = "information" + case _umpireCustomMail = "umpireCustomMail" + case _umpireCustomContact = "umpireCustomContact" + case _umpireCustomPhone = "umpireCustomPhone" + case _hideUmpireMail = "hideUmpireMail" + case _hideUmpirePhone = "hideUmpirePhone" + case _disableRankingFederalRuling = "disableRankingFederalRuling" + case _teamCountLimit = "teamCountLimit" + } + + private static func _decodePayment(container: KeyedDecodingContainer) throws -> TournamentPayment? { + var data = try container.decodeIfPresent(Data.self, forKey: ._payment) + if data == nil { + data = try container.decodeIfPresent(Data.self, forKey: .payment) + } + + if let data { + do { + let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue) + let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue } + return TournamentPayment(rawValue: sequence[18]) + } catch { + Logger.error(error) + } + } + return nil + } + + private func _encodePayment(container: inout KeyedEncodingContainer) throws { + guard let payment else { + try container.encodeNil(forKey: ._payment) + return + } + + let max: Int = TournamentPayment.allCases.count + var sequence = (1...18).map { _ in Int.random(in: (0..) throws -> Bool { + var data = try container.decodeIfPresent(Data.self, forKey: ._isCanceled) + if data == nil { + data = try container.decodeIfPresent(Data.self, forKey: .isCanceled) + } + if let data { + do { + let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue) + let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue } + return Bool.decodeInt(sequence[18]) + } catch { + Logger.error(error) + } + } + return false + } + + private func _encodeIscanceled(container: inout KeyedEncodingContainer) throws { + let max: Int = 9 + var sequence = (1...18).map { _ in Int.random(in: (0...max)) } + sequence.append(self.isCanceled.encodedValue) + sequence.append(contentsOf: (1...13).map { _ in Int.random(in: (0...max ))} ) + + let stringCombo: [String] = sequence.map { $0.formatted() } + let joined: String = stringCombo.joined(separator: "") + if let data = joined.data(using: .utf8) { + let encryped: Data = try data.encrypt(pass: CryptoKey.pass.rawValue) + try container.encode(encryped, forKey: ._isCanceled) + } + } + + required init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(String.self, forKey: ._id) ?? Store.randomId() + self.event = try container.decodeIfPresent(String.self, forKey: ._event) ?? nil + self.name = try container.decodeIfPresent(String.self, forKey: ._name) ?? nil + self.startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) ?? Date() + self.endDate = try container.decodeIfPresent(Date.self, forKey: ._endDate) ?? nil + self.creationDate = try container.decodeIfPresent(Date.self, forKey: ._creationDate) ?? Date() + self.isPrivate = try container.decodeIfPresent(Bool.self, forKey: ._isPrivate) ?? false + self.groupStageFormat = try container.decodeIfPresent(MatchFormat.self, forKey: ._groupStageFormat) ?? nil + self.roundFormat = try container.decodeIfPresent(MatchFormat.self, forKey: ._roundFormat) ?? nil + self.loserRoundFormat = try container.decodeIfPresent(MatchFormat.self, forKey: ._loserRoundFormat) ?? nil + self.groupStageSortMode = try container.decodeIfPresent(GroupStageOrderingMode.self, forKey: ._groupStageSortMode) ?? GroupStageOrderingMode.snake + self.groupStageCount = try container.decodeIfPresent(Int.self, forKey: ._groupStageCount) ?? 0 + self.rankSourceDate = try container.decodeIfPresent(Date.self, forKey: ._rankSourceDate) ?? nil + self.dayDuration = try container.decodeIfPresent(Int.self, forKey: ._dayDuration) ?? 0 + self.teamCount = try container.decodeIfPresent(Int.self, forKey: ._teamCount) ?? 0 + self.teamSorting = try container.decodeIfPresent(TeamSortingType.self, forKey: ._teamSorting) ?? TeamSortingType.inscriptionDate + self.federalCategory = try container.decodeIfPresent(TournamentCategory.self, forKey: ._federalCategory) ?? TournamentCategory.men + self.federalLevelCategory = try container.decodeIfPresent(TournamentLevel.self, forKey: ._federalLevelCategory) ?? TournamentLevel.unlisted + self.federalAgeCategory = try container.decodeIfPresent(FederalTournamentAge.self, forKey: ._federalAgeCategory) ?? FederalTournamentAge.unlisted + self.closedRegistrationDate = try container.decodeIfPresent(Date.self, forKey: ._closedRegistrationDate) ?? nil + self.groupStageAdditionalQualified = try container.decodeIfPresent(Int.self, forKey: ._groupStageAdditionalQualified) ?? 0 + self.courtCount = try container.decodeIfPresent(Int.self, forKey: ._courtCount) ?? 2 + self.prioritizeClubMembers = try container.decodeIfPresent(Bool.self, forKey: ._prioritizeClubMembers) ?? false + self.qualifiedPerGroupStage = try container.decodeIfPresent(Int.self, forKey: ._qualifiedPerGroupStage) ?? 0 + self.teamsPerGroupStage = try container.decodeIfPresent(Int.self, forKey: ._teamsPerGroupStage) ?? 0 + self.entryFee = try container.decodeIfPresent(Double.self, forKey: ._entryFee) ?? nil + self.payment = try Self._decodePayment(container: container) + self.additionalEstimationDuration = try container.decodeIfPresent(Int.self, forKey: ._additionalEstimationDuration) ?? 0 + self.isDeleted = try container.decodeIfPresent(Bool.self, forKey: ._isDeleted) ?? false + self.isCanceled = try Self._decodeIscanceled(container: container) + self.publishTeams = try container.decodeIfPresent(Bool.self, forKey: ._publishTeams) ?? false + self.publishSummons = try container.decodeIfPresent(Bool.self, forKey: ._publishSummons) ?? false + self.publishGroupStages = try container.decodeIfPresent(Bool.self, forKey: ._publishGroupStages) ?? false + self.publishBrackets = try container.decodeIfPresent(Bool.self, forKey: ._publishBrackets) ?? false + self.shouldVerifyGroupStage = try container.decodeIfPresent(Bool.self, forKey: ._shouldVerifyGroupStage) ?? false + self.shouldVerifyBracket = try container.decodeIfPresent(Bool.self, forKey: ._shouldVerifyBracket) ?? false + self.hideTeamsWeight = try container.decodeIfPresent(Bool.self, forKey: ._hideTeamsWeight) ?? false + self.publishTournament = try container.decodeIfPresent(Bool.self, forKey: ._publishTournament) ?? false + self.hidePointsEarned = try container.decodeIfPresent(Bool.self, forKey: ._hidePointsEarned) ?? false + self.publishRankings = try container.decodeIfPresent(Bool.self, forKey: ._publishRankings) ?? false + self.loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic + self.initialSeedRound = try container.decodeIfPresent(Int.self, forKey: ._initialSeedRound) ?? 0 + self.initialSeedCount = try container.decodeIfPresent(Int.self, forKey: ._initialSeedCount) ?? 0 + self.enableOnlineRegistration = try container.decodeIfPresent(Bool.self, forKey: ._enableOnlineRegistration) ?? false + self.registrationDateLimit = try container.decodeIfPresent(Date.self, forKey: ._registrationDateLimit) ?? nil + self.openingRegistrationDate = try container.decodeIfPresent(Date.self, forKey: ._openingRegistrationDate) ?? nil + self.waitingListLimit = try container.decodeIfPresent(Int.self, forKey: ._waitingListLimit) ?? nil + self.accountIsRequired = try container.decodeIfPresent(Bool.self, forKey: ._accountIsRequired) ?? true + self.licenseIsRequired = try container.decodeIfPresent(Bool.self, forKey: ._licenseIsRequired) ?? true + self.minimumPlayerPerTeam = try container.decodeIfPresent(Int.self, forKey: ._minimumPlayerPerTeam) ?? 2 + self.maximumPlayerPerTeam = try container.decodeIfPresent(Int.self, forKey: ._maximumPlayerPerTeam) ?? 2 + self.information = try container.decodeIfPresent(String.self, forKey: ._information) ?? nil + self.umpireCustomMail = try container.decodeIfPresent(String.self, forKey: ._umpireCustomMail) ?? nil + self.umpireCustomContact = try container.decodeIfPresent(String.self, forKey: ._umpireCustomContact) ?? nil + self.umpireCustomPhone = try container.decodeIfPresent(String.self, forKey: ._umpireCustomPhone) ?? nil + self.hideUmpireMail = try container.decodeIfPresent(Bool.self, forKey: ._hideUmpireMail) ?? false + self.hideUmpirePhone = try container.decodeIfPresent(Bool.self, forKey: ._hideUmpirePhone) ?? true + self.disableRankingFederalRuling = try container.decodeIfPresent(Bool.self, forKey: ._disableRankingFederalRuling) ?? false + self.teamCountLimit = try container.decodeIfPresent(Bool.self, forKey: ._teamCountLimit) ?? true + try super.init(from: decoder) + } + + public override func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: ._id) + try container.encode(self.event, forKey: ._event) + try container.encode(self.name, forKey: ._name) + try container.encode(self.startDate, forKey: ._startDate) + try container.encode(self.endDate, forKey: ._endDate) + try container.encode(self.creationDate, forKey: ._creationDate) + try container.encode(self.isPrivate, forKey: ._isPrivate) + try container.encode(self.groupStageFormat, forKey: ._groupStageFormat) + try container.encode(self.roundFormat, forKey: ._roundFormat) + try container.encode(self.loserRoundFormat, forKey: ._loserRoundFormat) + try container.encode(self.groupStageSortMode, forKey: ._groupStageSortMode) + try container.encode(self.groupStageCount, forKey: ._groupStageCount) + try container.encode(self.rankSourceDate, forKey: ._rankSourceDate) + try container.encode(self.dayDuration, forKey: ._dayDuration) + try container.encode(self.teamCount, forKey: ._teamCount) + try container.encode(self.teamSorting, forKey: ._teamSorting) + try container.encode(self.federalCategory, forKey: ._federalCategory) + try container.encode(self.federalLevelCategory, forKey: ._federalLevelCategory) + try container.encode(self.federalAgeCategory, forKey: ._federalAgeCategory) + try container.encode(self.closedRegistrationDate, forKey: ._closedRegistrationDate) + try container.encode(self.groupStageAdditionalQualified, forKey: ._groupStageAdditionalQualified) + try container.encode(self.courtCount, forKey: ._courtCount) + try container.encode(self.prioritizeClubMembers, forKey: ._prioritizeClubMembers) + try container.encode(self.qualifiedPerGroupStage, forKey: ._qualifiedPerGroupStage) + try container.encode(self.teamsPerGroupStage, forKey: ._teamsPerGroupStage) + try container.encode(self.entryFee, forKey: ._entryFee) + try _encodePayment(container: &container) + try container.encode(self.additionalEstimationDuration, forKey: ._additionalEstimationDuration) + try container.encode(self.isDeleted, forKey: ._isDeleted) + try _encodeIscanceled(container: &container) + try container.encode(self.publishTeams, forKey: ._publishTeams) + try container.encode(self.publishSummons, forKey: ._publishSummons) + try container.encode(self.publishGroupStages, forKey: ._publishGroupStages) + try container.encode(self.publishBrackets, forKey: ._publishBrackets) + try container.encode(self.shouldVerifyGroupStage, forKey: ._shouldVerifyGroupStage) + try container.encode(self.shouldVerifyBracket, forKey: ._shouldVerifyBracket) + try container.encode(self.hideTeamsWeight, forKey: ._hideTeamsWeight) + try container.encode(self.publishTournament, forKey: ._publishTournament) + try container.encode(self.hidePointsEarned, forKey: ._hidePointsEarned) + try container.encode(self.publishRankings, forKey: ._publishRankings) + try container.encode(self.loserBracketMode, forKey: ._loserBracketMode) + try container.encode(self.initialSeedRound, forKey: ._initialSeedRound) + try container.encode(self.initialSeedCount, forKey: ._initialSeedCount) + try container.encode(self.enableOnlineRegistration, forKey: ._enableOnlineRegistration) + try container.encode(self.registrationDateLimit, forKey: ._registrationDateLimit) + try container.encode(self.openingRegistrationDate, forKey: ._openingRegistrationDate) + try container.encode(self.waitingListLimit, forKey: ._waitingListLimit) + try container.encode(self.accountIsRequired, forKey: ._accountIsRequired) + try container.encode(self.licenseIsRequired, forKey: ._licenseIsRequired) + try container.encode(self.minimumPlayerPerTeam, forKey: ._minimumPlayerPerTeam) + try container.encode(self.maximumPlayerPerTeam, forKey: ._maximumPlayerPerTeam) + try container.encode(self.information, forKey: ._information) + try container.encode(self.umpireCustomMail, forKey: ._umpireCustomMail) + try container.encode(self.umpireCustomContact, forKey: ._umpireCustomContact) + try container.encode(self.umpireCustomPhone, forKey: ._umpireCustomPhone) + try container.encode(self.hideUmpireMail, forKey: ._hideUmpireMail) + try container.encode(self.hideUmpirePhone, forKey: ._hideUmpirePhone) + try container.encode(self.disableRankingFederalRuling, forKey: ._disableRankingFederalRuling) + try container.encode(self.teamCountLimit, forKey: ._teamCountLimit) + try super.encode(to: encoder) + } + + func eventValue() -> Event? { + guard let event = self.event else { return nil } + return Store.main.findById(event) + } + + public func copy(from other: any Storable) { + guard let tournament = other as? BaseTournament else { return } + self.id = tournament.id + self.event = tournament.event + self.name = tournament.name + self.startDate = tournament.startDate + self.endDate = tournament.endDate + self.creationDate = tournament.creationDate + self.isPrivate = tournament.isPrivate + self.groupStageFormat = tournament.groupStageFormat + self.roundFormat = tournament.roundFormat + self.loserRoundFormat = tournament.loserRoundFormat + self.groupStageSortMode = tournament.groupStageSortMode + self.groupStageCount = tournament.groupStageCount + self.rankSourceDate = tournament.rankSourceDate + self.dayDuration = tournament.dayDuration + self.teamCount = tournament.teamCount + self.teamSorting = tournament.teamSorting + self.federalCategory = tournament.federalCategory + self.federalLevelCategory = tournament.federalLevelCategory + self.federalAgeCategory = tournament.federalAgeCategory + self.closedRegistrationDate = tournament.closedRegistrationDate + self.groupStageAdditionalQualified = tournament.groupStageAdditionalQualified + self.courtCount = tournament.courtCount + self.prioritizeClubMembers = tournament.prioritizeClubMembers + self.qualifiedPerGroupStage = tournament.qualifiedPerGroupStage + self.teamsPerGroupStage = tournament.teamsPerGroupStage + self.entryFee = tournament.entryFee + self.payment = tournament.payment + self.additionalEstimationDuration = tournament.additionalEstimationDuration + self.isDeleted = tournament.isDeleted + self.isCanceled = tournament.isCanceled + self.publishTeams = tournament.publishTeams + self.publishSummons = tournament.publishSummons + self.publishGroupStages = tournament.publishGroupStages + self.publishBrackets = tournament.publishBrackets + self.shouldVerifyGroupStage = tournament.shouldVerifyGroupStage + self.shouldVerifyBracket = tournament.shouldVerifyBracket + self.hideTeamsWeight = tournament.hideTeamsWeight + self.publishTournament = tournament.publishTournament + self.hidePointsEarned = tournament.hidePointsEarned + self.publishRankings = tournament.publishRankings + self.loserBracketMode = tournament.loserBracketMode + self.initialSeedRound = tournament.initialSeedRound + self.initialSeedCount = tournament.initialSeedCount + self.enableOnlineRegistration = tournament.enableOnlineRegistration + self.registrationDateLimit = tournament.registrationDateLimit + self.openingRegistrationDate = tournament.openingRegistrationDate + self.waitingListLimit = tournament.waitingListLimit + self.accountIsRequired = tournament.accountIsRequired + self.licenseIsRequired = tournament.licenseIsRequired + self.minimumPlayerPerTeam = tournament.minimumPlayerPerTeam + self.maximumPlayerPerTeam = tournament.maximumPlayerPerTeam + self.information = tournament.information + self.umpireCustomMail = tournament.umpireCustomMail + self.umpireCustomContact = tournament.umpireCustomContact + self.umpireCustomPhone = tournament.umpireCustomPhone + self.hideUmpireMail = tournament.hideUmpireMail + self.hideUmpirePhone = tournament.hideUmpirePhone + self.disableRankingFederalRuling = tournament.disableRankingFederalRuling + self.teamCountLimit = tournament.teamCountLimit + } + + public static func relationships() -> [Relationship] { + return [ + Relationship(type: Event.self, keyPath: \BaseTournament.event), + ] + } + +} \ No newline at end of file diff --git a/PadelClubData/Data/Gen/Club.json b/PadelClubData/Data/Gen/Club.json new file mode 100644 index 0000000..14a9fc7 --- /dev/null +++ b/PadelClubData/Data/Gen/Club.json @@ -0,0 +1,92 @@ +{ + "models": [ + { + "name": "Club", + "synchronizable": true, + "observable": true, + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "creator", + "type": "String", + "optional": true, + "defaultValue": "nil", + "foreignKey": "CustomUser" + }, + { + "name": "name", + "type": "String", + "defaultValue": "\"\"" + }, + { + "name": "acronym", + "type": "String", + "defaultValue": "\"\"" + }, + { + "name": "phone", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "code", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "address", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "city", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "zipCode", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "latitude", + "type": "Double", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "longitude", + "type": "Double", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "courtCount", + "type": "Int", + "defaultValue": "2" + }, + { + "name": "broadcastCode", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "timezone", + "type": "String", + "optional": true, + "defaultValue": "TimeZone.current.identifier" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/Court.json b/PadelClubData/Data/Gen/Court.json new file mode 100644 index 0000000..ec2bbe4 --- /dev/null +++ b/PadelClubData/Data/Gen/Court.json @@ -0,0 +1,42 @@ +{ + "models": [ + { + "name": "Court", + "synchronizable": true, + "observable": true, + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "index", + "type": "Int" + }, + { + "name": "club", + "type": "String", + "foreignKey": "Club" + }, + { + "name": "name", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "exitAllowed", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "indoor", + "type": "Bool", + "defaultValue": "false" + } + ], + "tokenExemptedMethods": [] + } + ] +} diff --git a/PadelClubData/Data/Gen/CustomUser.json b/PadelClubData/Data/Gen/CustomUser.json new file mode 100644 index 0000000..ac32c3c --- /dev/null +++ b/PadelClubData/Data/Gen/CustomUser.json @@ -0,0 +1,136 @@ +{ + "models": [ + { + "name": "CustomUser", + "resource_name": "users", + "synchronizable": true, + "observable": true, + "tokenExemptedMethods": ["post"], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "username", + "type": "String" + }, + { + "name": "email", + "type": "String" + }, + { + "name": "clubs", + "type": "[String]", + "defaultValue": "[]" + }, + { + "name": "umpireCode", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "licenceId", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "firstName", + "type": "String" + }, + { + "name": "lastName", + "type": "String" + }, + { + "name": "phone", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "country", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "summonsMessageBody", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "summonsMessageSignature", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "summonsAvailablePaymentMethods", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "summonsDisplayFormat", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "summonsDisplayEntryFee", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "summonsUseFullCustomMessage", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "matchFormatsDefaultDuration", + "type": "[MatchFormat: Int]", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "bracketMatchFormatPreference", + "type": "MatchFormat", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "groupStageMatchFormatPreference", + "type": "MatchFormat", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "loserBracketMatchFormatPreference", + "type": "MatchFormat", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "loserBracketMode", + "type": "LoserBracketMode", + "defaultValue": ".automatic" + }, + { + "name": "deviceId", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "agents", + "type": "[String]", + "defaultValue": "[]" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/DateInterval.json b/PadelClubData/Data/Gen/DateInterval.json new file mode 100644 index 0000000..b7c1532 --- /dev/null +++ b/PadelClubData/Data/Gen/DateInterval.json @@ -0,0 +1,33 @@ +{ + "models": [ + { + "name": "DateInterval", + "synchronizable": true, + "observable": true, + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "event", + "type": "String" + }, + { + "name": "courtIndex", + "type": "Int" + }, + { + "name": "startDate", + "type": "Date" + }, + { + "name": "endDate", + "type": "Date" + } + ], + "tokenExemptedMethods": [] + } + ] +} diff --git a/PadelClubData/Data/Gen/Drawlog.json b/PadelClubData/Data/Gen/Drawlog.json new file mode 100644 index 0000000..8f872d9 --- /dev/null +++ b/PadelClubData/Data/Gen/Drawlog.json @@ -0,0 +1,47 @@ + +{ + "models": [ + { + "name": "DrawLog", + "synchronizable": true, + "sideStorable": true, + "observable": true, + "relationshipNames": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "tournament", + "type": "String", + "foreignKey": "Tournament" + }, + { + "name": "drawDate", + "type": "Date", + "defaultValue": "Date()" + }, + { + "name": "drawSeed", + "type": "Int" + }, + { + "name": "drawMatchIndex", + "type": "Int" + }, + { + "name": "drawTeamPosition", + "type": "TeamPosition", + "defaultValue": "TeamPosition.one" + }, + { + "name": "drawType", + "type": "DrawType", + "defaultValue": "DrawType.seed" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/Event.json b/PadelClubData/Data/Gen/Event.json new file mode 100644 index 0000000..0fa8d00 --- /dev/null +++ b/PadelClubData/Data/Gen/Event.json @@ -0,0 +1,48 @@ +{ + "models": [ + { + "name": "Event", + "synchronizable": true, + "observable": true, + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "creator", + "type": "String", + "optional": true, + "defaultValue": "nil", + "foreignKey": "CustomUser" + }, + { + "name": "club", + "type": "String", + "optional": true, + "defaultValue": "nil", + "foreignKey": "Club" + }, + { + "name": "creationDate", + "type": "Date", + "defaultValue": "Date()" + }, + { + "name": "name", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "tenupId", + "type": "String", + "optional": true, + "defaultValue": "nil" + } + ], + "tokenExemptedMethods": [] + } + ] +} diff --git a/PadelClubData/Data/Gen/GroupStage.json b/PadelClubData/Data/Gen/GroupStage.json new file mode 100644 index 0000000..ecfdad5 --- /dev/null +++ b/PadelClubData/Data/Gen/GroupStage.json @@ -0,0 +1,53 @@ +{ + "models": [ + { + "name": "GroupStage", + "synchronizable": true, + "observable": true, + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "tournament", + "type": "String", + "foreignKey": "Tournament" + }, + { + "name": "index", + "type": "Int" + }, + { + "name": "size", + "type": "Int" + }, + { + "name": "format", + "type": "MatchFormat", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "startDate", + "type": "Date", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "name", + "type": "String", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "step", + "type": "Int", + "defaultValue": "0" + } + ], + "tokenExemptedMethods": [] + } + ] +} diff --git a/PadelClubData/Data/Gen/Match.json b/PadelClubData/Data/Gen/Match.json new file mode 100644 index 0000000..913014c --- /dev/null +++ b/PadelClubData/Data/Gen/Match.json @@ -0,0 +1,83 @@ +{ + "models": [ + { + "name": "Match", + "synchronizable": true, + "observable": true, + "tokenExemptedMethods": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "round", + "type": "String", + "optional": true, + "foreignKey": "Round*" + }, + { + "name": "groupStage", + "type": "String", + "optional": true, + "foreignKey": "GroupStage*" + }, + { + "name": "startDate", + "type": "Date", + "optional": true + }, + { + "name": "endDate", + "type": "Date", + "optional": true + }, + { + "name": "index", + "type": "Int" + }, + { + "name": "format", + "type": "MatchFormat", + "optional": true + }, + { + "name": "servingTeamId", + "type": "String", + "optional": true + }, + { + "name": "winningTeamId", + "type": "String", + "optional": true + }, + { + "name": "losingTeamId", + "type": "String", + "optional": true + }, + { + "name": "name", + "type": "String", + "optional": true + }, + { + "name": "disabled", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "courtIndex", + "type": "Int", + "optional": true + }, + { + "name": "confirmed", + "type": "Bool", + "defaultValue": "false" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/MatchScheduler.json b/PadelClubData/Data/Gen/MatchScheduler.json new file mode 100644 index 0000000..132bb36 --- /dev/null +++ b/PadelClubData/Data/Gen/MatchScheduler.json @@ -0,0 +1,91 @@ +{ + "models": [ + { + "name": "MatchScheduler", + "resource_name": "match-scheduler", + "synchronizable": false, + "observable": true, + "tokenExemptedMethods": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "tournament", + "type": "String", + "foreignKey": "Tournament" + }, + { + "name": "timeDifferenceLimit", + "type": "Int", + "defaultValue": "5" + }, + { + "name": "loserBracketRotationDifference", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "upperBracketRotationDifference", + "type": "Int", + "defaultValue": "1" + }, + { + "name": "accountUpperBracketBreakTime", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "accountLoserBracketBreakTime", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "randomizeCourts", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "rotationDifferenceIsImportant", + "type": "Bool" + }, + { + "name": "shouldHandleUpperRoundSlice", + "type": "Bool" + }, + { + "name": "shouldEndRoundBeforeStartingNext", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "groupStageChunkCount", + "type": "Int", + "optional": true + }, + { + "name": "overrideCourtsUnavailability", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "shouldTryToFillUpCourtsAvailable", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "courtsAvailable", + "type": "Set", + "defaultValue": "Set()" + }, + { + "name": "simultaneousStart", + "type": "Bool", + "defaultValue": "true" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/MonthData.json b/PadelClubData/Data/Gen/MonthData.json new file mode 100644 index 0000000..896038f --- /dev/null +++ b/PadelClubData/Data/Gen/MonthData.json @@ -0,0 +1,71 @@ +{ + "models": [ + { + "name": "MonthData", + "resource_name": "month-data", + "synchronizable": false, + "observable": true, + "tokenExemptedMethods": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "monthKey", + "type": "String" + }, + { + "name": "creationDate", + "type": "Date" + }, + { + "name": "maleUnrankedValue", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "femaleUnrankedValue", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "maleCount", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "femaleCount", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "anonymousCount", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "incompleteMode", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "dataModelIdentifier", + "type": "String", + "optional": true + }, + { + "name": "fileModelIdentifier", + "type": "String", + "optional": true + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/PlayerRegistration.json b/PadelClubData/Data/Gen/PlayerRegistration.json new file mode 100644 index 0000000..1f5eb98 --- /dev/null +++ b/PadelClubData/Data/Gen/PlayerRegistration.json @@ -0,0 +1,122 @@ +{ + "models": [ + { + "name": "PlayerRegistration", + "synchronizable": true, + "sideStorable": true, + "observable": true, + "relationshipNames": ["teamRegistration"], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "teamRegistration", + "type": "String", + "optional": true, + "foreignKey": "TeamRegistration" + }, + { + "name": "firstName", + "type": "String" + }, + { + "name": "lastName", + "type": "String" + }, + { + "name": "licenceId", + "type": "String", + "optional": true + }, + { + "name": "rank", + "type": "Int", + "optional": true + }, + { + "name": "paymentType", + "type": "PlayerPaymentType", + "optional": true + }, + { + "name": "sex", + "type": "PlayerSexType", + "optional": true + }, + { + "name": "tournamentPlayed", + "type": "Int", + "optional": true + }, + { + "name": "points", + "type": "Double", + "optional": true + }, + { + "name": "clubName", + "type": "String", + "optional": true + }, + { + "name": "ligueName", + "type": "String", + "optional": true + }, + { + "name": "assimilation", + "type": "String", + "optional": true + }, + { + "name": "phoneNumber", + "type": "String", + "optional": true + }, + { + "name": "email", + "type": "String", + "optional": true + }, + { + "name": "birthdate", + "type": "String", + "optional": true + }, + { + "name": "computedRank", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "source", + "type": "PlayerRegistration.PlayerDataSource", + "optional": true + }, + { + "name": "hasArrived", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "coach", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "captain", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "registeredOnline", + "type": "Bool", + "defaultValue": "false" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/Purchase.json b/PadelClubData/Data/Gen/Purchase.json new file mode 100644 index 0000000..a8dd49b --- /dev/null +++ b/PadelClubData/Data/Gen/Purchase.json @@ -0,0 +1,51 @@ +{ + "models": [ + { + "name": "Purchase", + "synchronizable": true, + "properties": [ + { + "name": "id", + "type": "UInt64", + "defaultValue": "0" + }, + { + "name": "user", + "type": "String", + "defaultValue": "\"\"", + "encryption": "standard", + "foreignKey": "CustomUser" + }, + { + "name": "purchaseDate", + "type": "Date" + }, + { + "name": "productId", + "type": "String", + "defaultValue": "\"\"" + }, + { + "name": "quantity", + "type": "Int", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "revocationDate", + "type": "Date", + "optional": true, + "defaultValue": "nil" + }, + { + "name": "expirationDate", + "type": "Date", + "optional": true, + "defaultValue": "nil" + } + ], + "tokenExemptedMethods": [], + "relationshipNames": [] + } + ] +} diff --git a/PadelClubData/Data/Gen/Round.json b/PadelClubData/Data/Gen/Round.json new file mode 100644 index 0000000..48a6cf7 --- /dev/null +++ b/PadelClubData/Data/Gen/Round.json @@ -0,0 +1,53 @@ +{ + "models": [ + { + "name": "Round", + "synchronizable": true, + "sideStorable": true, + "observable": true, + "relationshipNames": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "tournament", + "type": "String", + "foreignKey": "Tournament" + }, + { + "name": "index", + "type": "Int" + }, + { + "name": "parent", + "type": "String", + "optional": true + }, + { + "name": "format", + "type": "MatchFormat", + "optional": true, + "private": true + }, + { + "name": "startDate", + "type": "Date", + "optional": true + }, + { + "name": "groupStageLoserBracket", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "loserBracketMode", + "type": "LoserBracketMode", + "defaultValue": ".automatic" + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/TeamRegistration.json b/PadelClubData/Data/Gen/TeamRegistration.json new file mode 100644 index 0000000..995d08e --- /dev/null +++ b/PadelClubData/Data/Gen/TeamRegistration.json @@ -0,0 +1,118 @@ +{ + "models": [ + { + "name": "TeamRegistration", + "synchronizable": true, + "sideStorable": true, + "observable": true, + "relationshipNames": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "tournament", + "type": "String" + }, + { + "name": "groupStage", + "type": "String", + "optional": true, + "foreignKey": "GroupStage*" + }, + { + "name": "registrationDate", + "type": "Date", + "optional": true + }, + { + "name": "callDate", + "type": "Date", + "optional": true + }, + { + "name": "bracketPosition", + "type": "Int", + "optional": true + }, + { + "name": "groupStagePosition", + "type": "Int", + "optional": true + }, + { + "name": "comment", + "type": "String", + "optional": true + }, + { + "name": "source", + "type": "String", + "optional": true + }, + { + "name": "sourceValue", + "type": "String", + "optional": true + }, + { + "name": "logo", + "type": "String", + "optional": true + }, + { + "name": "name", + "type": "String", + "optional": true + }, + { + "name": "walkOut", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "wildCardBracket", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "wildCardGroupStage", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "weight", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "lockedWeight", + "type": "Int", + "optional": true + }, + { + "name": "confirmationDate", + "type": "Date", + "optional": true + }, + { + "name": "qualified", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "finalRanking", + "type": "Int", + "optional": true + }, + { + "name": "pointsEarned", + "type": "Int", + "optional": true + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/TeamScore.json b/PadelClubData/Data/Gen/TeamScore.json new file mode 100644 index 0000000..9165c44 --- /dev/null +++ b/PadelClubData/Data/Gen/TeamScore.json @@ -0,0 +1,44 @@ +{ + "models": [ + { + "name": "TeamScore", + "synchronizable": true, + "sideStorable": true, + "observable": true, + "relationshipNames": ["match"], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "match", + "type": "String", + "foreignKey": "Match*" + }, + { + "name": "teamRegistration", + "type": "String", + "optional": true, + "foreignKey": "TeamRegistration*" + }, + { + "name": "score", + "type": "String", + "optional": true + }, + { + "name": "walkOut", + "type": "Int", + "optional": true + }, + { + "name": "luckyLoser", + "type": "Int", + "optional": true + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/Tournament.json b/PadelClubData/Data/Gen/Tournament.json new file mode 100644 index 0000000..1262af0 --- /dev/null +++ b/PadelClubData/Data/Gen/Tournament.json @@ -0,0 +1,310 @@ +{ + "models": [ + { + "name": "Tournament", + "synchronizable": true, + "copyable": true, + "observable": true, + "relationshipNames": [], + "properties": [ + { + "name": "id", + "type": "String", + "defaultValue": "Store.randomId()" + }, + { + "name": "event", + "type": "String", + "optional": true, + "foreignKey": "Event" + }, + { + "name": "name", + "type": "String", + "optional": true + }, + { + "name": "startDate", + "type": "Date" + }, + { + "name": "endDate", + "type": "Date", + "optional": true + }, + { + "name": "creationDate", + "type": "Date", + "private": true + }, + { + "name": "isPrivate", + "type": "Bool" + }, + { + "name": "groupStageFormat", + "type": "MatchFormat", + "optional": true, + "private": true + }, + { + "name": "roundFormat", + "type": "MatchFormat", + "optional": true, + "private": true + }, + { + "name": "loserRoundFormat", + "type": "MatchFormat", + "optional": true, + "private": true + }, + { + "name": "groupStageSortMode", + "type": "GroupStageOrderingMode", + "defaultValue": "GroupStageOrderingMode.snake" + }, + { + "name": "groupStageCount", + "type": "Int" + }, + { + "name": "rankSourceDate", + "type": "Date", + "optional": true + }, + { + "name": "dayDuration", + "type": "Int" + }, + { + "name": "teamCount", + "type": "Int" + }, + { + "name": "teamSorting", + "type": "TeamSortingType", + "defaultValue": "TeamSortingType.inscriptionDate" + }, + { + "name": "federalCategory", + "type": "TournamentCategory", + "defaultValue": "TournamentCategory.men" + }, + { + "name": "federalLevelCategory", + "type": "TournamentLevel", + "defaultValue": "TournamentLevel.unlisted" + }, + { + "name": "federalAgeCategory", + "type": "FederalTournamentAge", + "defaultValue": "FederalTournamentAge.unlisted" + }, + { + "name": "closedRegistrationDate", + "type": "Date", + "optional": true + }, + { + "name": "groupStageAdditionalQualified", + "type": "Int" + }, + { + "name": "courtCount", + "type": "Int", + "defaultValue": "2" + }, + { + "name": "prioritizeClubMembers", + "type": "Bool" + }, + { + "name": "qualifiedPerGroupStage", + "type": "Int" + }, + { + "name": "teamsPerGroupStage", + "type": "Int" + }, + { + "name": "entryFee", + "type": "Double", + "optional": true + }, + { + "name": "payment", + "type": "TournamentPayment", + "optional": true, + "defaultValue": "nil", + "encryption": "tournament_payment", + "codingKey": "globalId" + }, + { + "name": "additionalEstimationDuration", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "isDeleted", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "isCanceled", + "type": "Bool", + "defaultValue": "false", + "encryption": "tournament_iscanceled", + "codingKey": "localId" + }, + { + "name": "publishTeams", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "publishSummons", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "publishGroupStages", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "publishBrackets", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "shouldVerifyGroupStage", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "shouldVerifyBracket", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "hideTeamsWeight", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "publishTournament", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "hidePointsEarned", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "publishRankings", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "loserBracketMode", + "type": "LoserBracketMode", + "defaultValue": ".automatic" + }, + { + "name": "initialSeedRound", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "initialSeedCount", + "type": "Int", + "defaultValue": "0" + }, + { + "name": "enableOnlineRegistration", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "registrationDateLimit", + "type": "Date", + "optional": true + }, + { + "name": "openingRegistrationDate", + "type": "Date", + "optional": true + }, + { + "name": "waitingListLimit", + "type": "Int", + "optional": true + }, + { + "name": "accountIsRequired", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "licenseIsRequired", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "minimumPlayerPerTeam", + "type": "Int", + "defaultValue": 2 + }, + { + "name": "maximumPlayerPerTeam", + "type": "Int", + "defaultValue": 2 + }, + { + "name": "information", + "type": "String", + "optional": true + }, + { + "name": "umpireCustomMail", + "type": "String", + "optional": true + }, + { + "name": "umpireCustomContact", + "type": "String", + "optional": true + }, + { + "name": "umpireCustomPhone", + "type": "String", + "optional": true + }, + { + "name": "hideUmpireMail", + "type": "Bool", + "defaultValue": "false" + }, + { + "name": "hideUmpirePhone", + "type": "Bool", + "defaultValue": "true" + }, + { + "name": "disableRankingFederalRuling", + "type": "Bool", + "defaultValue": "false", + "optional": false + }, + { + "name": "teamCountLimit", + "type": "Bool", + "defaultValue": "true", + "optional": false + } + ] + } + ] +} diff --git a/PadelClubData/Data/Gen/generator.py b/PadelClubData/Data/Gen/generator.py new file mode 100644 index 0000000..ec8f2fe --- /dev/null +++ b/PadelClubData/Data/Gen/generator.py @@ -0,0 +1,591 @@ +import json +import re +import os +from pathlib import Path +from typing import Dict, List, Any +import argparse +import sys +import logging +from datetime import datetime +import inflect + +class SwiftModelGenerator: + def __init__(self, json_data: Dict[str, Any]): + self.data = json_data + self.pluralizer = inflect.engine() + + def generate_model(self, model_data: Dict[str, Any]) -> str: + model_name = model_data["name"] + is_sync = model_data.get("synchronizable", False) + is_observable = model_data.get("observable", False) + properties = model_data["properties"] + + # Get protocol specific configurations + resource = self.make_resource_name(model_name) + resource_name = model_data.get("resource_name", resource) + token_exempted = model_data.get("tokenExemptedMethods", []) + + lines = ["// Generated by SwiftModelGenerator", "// Do not modify this file manually", ""] + + # Import statement + lines.append("import Foundation") + lines.append("import LeStorage") + if is_observable: + lines.append("import SwiftUI") + lines.append("") + + # Class declaration + if is_observable: + lines.append("@Observable") + protocol = "SyncedStorable" if is_sync else "Storable" + base_class = "SyncedModelObject" if is_sync else "BaseModelObject" + lines.append(f"public class Base{model_name}: {base_class}, {protocol} {{") + lines.append("") + + # Add SyncedStorable protocol requirements + lines.extend(self._generate_protocol_requirements(resource_name, token_exempted)) + lines.append("") + + # Properties + for prop in properties: + swift_type = prop["type"] + if prop.get("optional", False): + swift_type += "?" + default_value = prop.get("defaultValue", "nil" if prop.get("optional", False) else self._get_default_value(swift_type)) + lines.append(f" public var {prop['name']}: {swift_type} = {default_value}") + + lines.append("") + + # Add constructor + lines.extend(self._generate_constructor(model_name, properties)) + lines.append("") + + # CodingKeys + lines.extend(self._generate_coding_keys(model_name, properties, is_observable)) + lines.append("") + + # Encryption methods + encrypted_props = [p for p in properties if "encryption" in p] + if encrypted_props: + lines.extend(self._generate_encryption_methods(properties)) + lines.append("") + + # Codable implementation + lines.extend(self._generate_decoder(model_name, properties, is_observable, is_sync)) + lines.append("") + lines.extend(self._generate_encoder(properties, is_observable, is_sync)) + lines.append("") + + # Foreign Key convenience + foreign_key_methods = self._generate_foreign_key_methods(properties) + if foreign_key_methods: + lines.extend(foreign_key_methods) + # lines.append("") + + # Copy method + lines.extend(self._generate_copy_method(model_name, properties)) + lines.append("") + + # Add relationships function + lines.extend(self._generate_relationships(model_name, properties)) + lines.append("") + + lines.append("}") + return "\n".join(lines) + + def _generate_constructor(self, model_name: str, properties: List[Dict[str, Any]]) -> List[str]: + """Generate a constructor with all properties as parameters with default values.""" + + lines = [" public init("] + + # Generate parameter list + params = [] + for prop in properties: + name = prop['name'] + type_name = prop['type'] + is_optional = prop.get("optional", False) + + # Always include a default value + default_value = prop.get("defaultValue") + if default_value is None: + if is_optional: + default_value = "nil" + else: + default_value = self._get_default_value(type_name) + + # Format the parameter with its type and default value + param = f" {name}: {type_name}" + if is_optional: + param += "?" + param += f" = {default_value}" + params.append(param) + + # Join parameters with commas + lines.extend([f"{param}," for param in params[:-1]]) + lines.append(f"{params[-1]}") # Last parameter without comma + + # Constructor body + lines.extend([ + " ) {", + " super.init()", + ]) + + # Property assignments + for prop in properties: + name = prop['name'] + lines.append(f" self.{name} = {name}") + + lines.append(" }") + + lines.extend([ + " required public override init() {", + " super.init()", + " }", + + ]) + + + return lines + + def _generate_foreign_key_methods(self, properties: List[Dict[str, Any]]) -> List[str]: + lines = [] + for prop in properties: + if "foreignKey" in prop: + foreign_key = prop["foreignKey"] + prop_name = prop["name"] + method_name = f"{prop_name}Value" + is_optional = prop.get("optional", False) + + lines.extend([f" func {method_name}() -> {foreign_key.rstrip('*')}? {{"]) + + if is_optional: + lines.extend([ + f" guard let {prop_name} = self.{prop_name} else {{ return nil }}" + ]) + + if foreign_key.endswith("*"): + foreign_key = foreign_key[:-1] # Remove the asterisk + lines.extend([ + f" return self.store?.findById({prop_name})" + ]) + else: + lines.extend([ + f" return Store.main.findById({prop_name})" + ]) + + lines.extend([" }", ""]) # Close the method and add a blank line + return lines + + def _generate_coding_keys(self, model_name: str, properties: List[Dict[str, Any]], is_observable: bool) -> List[str]: + lines = [" public enum CodingKeys: String, CodingKey {"] + + if model_name == 'Tournament': + lines.append(" case isCanceled = \"isCanceled\"") + lines.append(" case payment = \"payment\"") + + for prop in properties: + name = prop['name'] + # Add underscore prefix to case name if observable + case_name = f"_{name}" if is_observable else name + + # Use custom codingKey if provided, otherwise use the property name + coding_key_value = prop.get("codingKey", name) + + lines.append(f" case {case_name} = \"{coding_key_value}\"") + + lines.append(" }") + return lines + + def _generate_encryption_methods(self, properties: List[Dict[str, Any]]) -> List[str]: + lines = [] + for prop in properties: + if "encryption" in prop: + name = prop['name'] + enc_type = prop['encryption'] + if enc_type == "tournament_payment": + lines.extend([ + f" private static func _decode{name.capitalize()}(container: KeyedDecodingContainer) throws -> TournamentPayment? {{", + f" var data = try container.decodeIfPresent(Data.self, forKey: ._{name})", + " if data == nil {", + " data = try container.decodeIfPresent(Data.self, forKey: .payment)", + " }", + " ", + " if let data {", + " do {", + " let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue)", + " let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue }", + " return TournamentPayment(rawValue: sequence[18])", + " } catch {", + " Logger.error(error)", + " }", + " }", + " return nil", + " }", + "", + f" private func _encode{name.capitalize()}(container: inout KeyedEncodingContainer) throws {{", + f" guard let {name} else {{", + f" try container.encodeNil(forKey: ._{name})", + " return", + " }", + " ", + " let max: Int = TournamentPayment.allCases.count", + " var sequence = (1...18).map { _ in Int.random(in: (0..) throws -> Bool {{", + f" var data = try container.decodeIfPresent(Data.self, forKey: ._{name})", + " if data == nil {", + " data = try container.decodeIfPresent(Data.self, forKey: .isCanceled)", + " }", + " if let data {", + " do {", + " let decoded: String = try data.decryptData(pass: CryptoKey.pass.rawValue)", + " let sequence = decoded.compactMap { NumberFormatter.standard.number(from: String($0))?.intValue }", + " return Bool.decodeInt(sequence[18])", + " } catch {", + " Logger.error(error)", + " }", + " }", + " return false", + " }", + "", + f" private func _encode{name.capitalize()}(container: inout KeyedEncodingContainer) throws {{", + " let max: Int = 9", + " var sequence = (1...18).map { _ in Int.random(in: (0...max)) }", + f" sequence.append(self.{name}.encodedValue)", + " sequence.append(contentsOf: (1...13).map { _ in Int.random(in: (0...max ))} )", + " ", + " let stringCombo: [String] = sequence.map { $0.formatted() }", + " let joined: String = stringCombo.joined(separator: \"\")", + " if let data = joined.data(using: .utf8) {", + " let encryped: Data = try data.encrypt(pass: CryptoKey.pass.rawValue)", + f" try container.encode(encryped, forKey: ._{name})", + " }", + " }" + ]) + return lines + + def _generate_decoder(self, model_name: str, properties: List[Dict[str, Any]], is_observable: bool, is_sync: bool = False) -> List[str]: + lines = [" required init(from decoder: Decoder) throws {", + " let container = try decoder.container(keyedBy: CodingKeys.self)"] + + for prop in properties: + name = prop['name'] + type_name = prop['type'] + is_optional = prop.get("optional", False) + default_value = prop.get("defaultValue", "nil" if is_optional else self._get_default_value(type_name)) + option = prop.get("option") + + # Use the correct case reference based on observable flag + case_ref = f"_{name}" if is_observable else name + + if "encryption" in prop: + enc_type = prop['encryption'] + if enc_type == "standard": + if is_optional: + lines.append(f" self.{name} = try container.decodeEncryptedIfPresent(key: .{case_ref})") + else: + lines.append(f" self.{name} = try container.decodeEncrypted(key: .{case_ref})") + elif enc_type in ["tournament_payment", "tournament_iscanceled"]: + lines.append(f" self.{name} = try Self._decode{name.capitalize()}(container: container)") + else: + # Handle Date with fractional option + if type_name == "Date" and option == "fractional": + if is_optional: + lines.append(f" if let dateString = try container.decodeIfPresent(String.self, forKey: .{case_ref}) {{") + lines.append(f" self.{name} = Date.iso8601FractionalFormatter.date(from: dateString)") + lines.append(" } else {") + lines.append(f" self.{name} = {default_value}") + lines.append(" }") + else: + lines.append(f" let dateString = try container.decode(String.self, forKey: .{case_ref})") + lines.append(f" self.{name} = Date.iso8601FractionalFormatter.date(from: dateString) ?? {default_value}") + else: + lines.append(f" self.{name} = try container.decodeIfPresent({type_name}.self, forKey: .{case_ref}) ?? {default_value}") + + lines.append(" try super.init(from: decoder)") + + lines.append(" }") + return lines + + def _generate_encoder(self, properties: List[Dict[str, Any]], is_observable: bool, is_sync: bool = False) -> List[str]: + lines = [" public override func encode(to encoder: Encoder) throws {", + " var container = encoder.container(keyedBy: CodingKeys.self)"] + + for prop in properties: + name = prop['name'] + type_name = prop['type'] + is_optional = prop.get('optional', False) + option = prop.get("option") + + # Use the correct case reference based on observable flag + case_ref = f"_{name}" if is_observable else name + + if "encryption" in prop: + enc_type = prop['encryption'] + if enc_type == "standard": + if is_optional: + lines.append(f" try container.encodeAndEncryptIfPresent(self.{name}?.data(using: .utf8), forKey: .{case_ref})") + else: + lines.append(f" try container.encodeAndEncryptIfPresent(self.{name}.data(using: .utf8), forKey: .{case_ref})") + elif enc_type in ["tournament_payment", "tournament_iscanceled"]: + lines.append(f" try _encode{name.capitalize()}(container: &container)") + else: + if type_name == "Date" and option == "fractional": + if is_optional: + lines.append(f" if let date = self.{name} {{") + lines.append(f" try container.encode(Date.iso8601FractionalFormatter.string(from: date), forKey: .{case_ref})") + lines.append(" } else {") + lines.append(f" try container.encodeNil(forKey: .{case_ref})") + lines.append(" }") + else: + lines.append(f" try container.encode(Date.iso8601FractionalFormatter.string(from: self.{name}), forKey: .{case_ref})") + else: + lines.append(f" try container.encode(self.{name}, forKey: .{case_ref})") + + lines.append(" try super.encode(to: encoder)") + lines.append(" }") + return lines + + def _generate_copy_method(self, model_name: str, properties: List[Dict[str, Any]]) -> List[str]: + + model_variable = model_name.lower() + lines = [f" public func copy(from other: any Storable) {{"] + lines.append(f" guard let {model_variable} = other as? Base{model_name} else {{ return }}") + + for prop in properties: + name = prop['name'] + lines.append(f" self.{name} = {model_variable}.{name}") + + lines.append(" }") + return lines + + def _generate_protocol_requirements(self, resource_name: str, token_exempted: List[str]) -> List[str]: + """Generate the static functions required by SyncedStorable protocol.""" + # Convert HTTP methods to proper format + formatted_methods = [f".{method.lower()}" for method in token_exempted] + methods_str = ", ".join(formatted_methods) if formatted_methods else "" + + return [ + f" public static func resourceName() -> String {{ return \"{resource_name}\" }}", + f" public static func tokenExemptedMethods() -> [HTTPMethod] {{ return [{methods_str}] }}", + ] + + def _generate_relationships(self, model_name, properties: List[Dict[str, Any]]) -> List[str]: + # Find all properties with foreign keys + foreign_key_props = [p for p in properties if "foreignKey" in p] + + if not foreign_key_props: + # If no foreign keys, return empty array + return [ + " public static func relationships() -> [Relationship] {", + " return []", + " }" + ] + + lines = [ + " public static func relationships() -> [Relationship] {", + " return [" + ] + + # Generate relationship entries + for prop in foreign_key_props: + name = prop["name"] + foreign_key = prop["foreignKey"].rstrip('*') # Remove asterisk if present + lines.append(f" Relationship(type: {foreign_key}.self, keyPath: \\Base{model_name}.{name}),") + + # Close the array and function + lines.extend([ + " ]", + " }" + ]) + + return lines + + def _get_default_value(self, type_name: str) -> str: + """Get default value for non-optional types""" + if "String" in type_name: + return "\"\"" + elif "Int" in type_name: + return "0" + elif "Double" in type_name: + return "0.0" + elif "Bool" in type_name: + return "false" + elif "Date" in type_name: + return "Date()" + else: + return "nil" # For any other type + + def get_output_filename(self, model_name: str) -> str: + """Generate the output filename for a model.""" + return f"Base{model_name}.swift" + + def make_resource_name(self, text): + p = inflect.engine() + # Split camelCase into words + words = re.findall('[A-Z][^A-Z]*', text) + + if not words: + words = [text] + + words = [word.lower() for word in words] + words[-1] = p.plural(words[-1]) + return '-'.join(words) + +def process_directory(input_dir: str, output_dir: str, logger: logging.Logger, dry_run: bool = False) -> int: + """Process all JSON files in the input directory and generate Swift models.""" + try: + input_path = validate_directory(input_dir) + if not dry_run: + output_path = validate_directory(output_dir, create=True) + + json_files = list(input_path.glob("*.json")) + if not json_files: + logger.warning(f"No JSON files found in '{input_dir}'") + return 0 + + logger.info(f"Found {len(json_files)} JSON files to process") + successful_files = 0 + + for json_file in json_files: + try: + with open(json_file, 'r') as f: + json_data = json.load(f) + + generator = SwiftModelGenerator(json_data) + + # Generate each model in the JSON file + for model in json_data["models"]: + model_name = model["name"] + swift_code = generator.generate_model(model) + + if dry_run: + logger.info(f"Would generate Base{model_name}.swift") + continue + + # Write to output file with Base prefix + output_file = output_path / generator.get_output_filename(model_name) + with open(output_file, 'w') as f: + f.write(swift_code) + + logger.info(f"Generated Base{model_name}.swift") + + successful_files += 1 + + except json.JSONDecodeError as e: + logger.error(f"Error parsing {json_file.name}: {e}") + except KeyError as e: + logger.error(f"Missing required key in {json_file.name}: {e}") + except Exception as e: + logger.error(f"Error processing {json_file.name}: {e}") + + return successful_files + + except Exception as e: + logger.error(f"Fatal error: {e}") + return 0 + + +def setup_logging(verbose: bool) -> logging.Logger: + """Configure logging based on verbosity level.""" + logger = logging.getLogger('SwiftModelGenerator') + handler = logging.StreamHandler() + + if verbose: + logger.setLevel(logging.DEBUG) + handler.setFormatter(logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s' + )) + else: + logger.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter('%(message)s')) + + logger.addHandler(handler) + return logger + +def validate_directory(path: str, create: bool = False) -> Path: + """Validate and optionally create a directory.""" + dir_path = Path(path) + if dir_path.exists(): + if not dir_path.is_dir(): + raise ValueError(f"'{path}' exists but is not a directory") + elif create: + dir_path.mkdir(parents=True) + else: + raise ValueError(f"Directory '{path}' does not exist") + return dir_path + +def main(): + parser = argparse.ArgumentParser( + description="Generate Swift model classes from JSON definitions", + epilog="Example: %(prog)s -i ./model_definitions -o ../MyProject/Models" + ) + + parser.add_argument( + "-i", "--input-dir", + required=True, + help="Directory containing JSON model definitions" + ) + + parser.add_argument( + "-o", "--output-dir", + required=True, + help="Directory where Swift files will be generated" + ) + + parser.add_argument( + "-v", "--verbose", + action="store_true", + help="Enable verbose output" + ) + + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be generated without actually creating files" + ) + + parser.add_argument( + "--version", + action="version", + version="%(prog)s 1.0.0" + ) + + args = parser.parse_args() + + # Setup logging + logger = setup_logging(args.verbose) + + # Process the directories + start_time = datetime.now() + logger.info("Starting model generation...") + + successful_files = process_directory( + args.input_dir, + args.output_dir, + logger, + args.dry_run + ) + + # Report results + duration = datetime.now() - start_time + logger.info(f"\nGeneration completed in {duration.total_seconds():.2f} seconds") + logger.info(f"Successfully processed {successful_files} files") + + # Return appropriate exit code + sys.exit(0 if successful_files > 0 else 1) + +if __name__ == "__main__": + main() diff --git a/PadelClubData/Data/GroupStage.swift b/PadelClubData/Data/GroupStage.swift new file mode 100644 index 0000000..bf655cb --- /dev/null +++ b/PadelClubData/Data/GroupStage.swift @@ -0,0 +1,684 @@ +// +// GroupStage.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage +internal import Algorithms +import SwiftUI + +@Observable +public final class GroupStage: BaseGroupStage, SideStorable { + + public var matchFormat: MatchFormat { + get { + format ?? .defaultFormatForMatchType(.groupStage) + } + set { + format = newValue + } + } + + var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.tournament) + } + + // MARK: - Computed dependencies + + public func _matches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter { $0.groupStage == self.id }.sorted(by: \.index) +// Store.main.filter { $0.groupStage == self.id } + } + + public func tournamentObject() -> Tournament? { + Store.main.findById(self.tournament) + } + + // MARK: - + + public func teamAt(groupStagePosition: Int) -> TeamRegistration? { + if step > 0 { + return teams().first(where: { $0.groupStagePositionAtStep(step) == groupStagePosition }) + } + return teams().first(where: { $0.groupStagePosition == groupStagePosition }) + } + + public func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { + if let name { return name } + + var stepLabel = "" + if step > 0 { + stepLabel = " (" + (step + 1).ordinalFormatted(feminine: true) + " phase)" + } + + switch displayStyle { + case .title: + return "Poule \(index + 1)" + stepLabel + case .wide: + return "Poule \(index + 1)" + case .short: + return "#\(index + 1)" + } + } + + var computedOrder: Int { + index + step * 100 + } + + func isRunning() -> Bool { // at least a match has started + _matches().anySatisfy({ $0.isRunning() }) + } + + public func hasStarted() -> Bool { // meaning at least one match is over + _matches().filter { $0.hasEnded() }.isEmpty == false + } + + public func hasEnded() -> Bool { + let _matches = _matches() + if _matches.isEmpty { return false } + //guard teams().count == size else { return false } + return _matches.anySatisfy { $0.hasEnded() == false } == false + } + + fileprivate func _createMatch(index: Int) -> Match { + let match: Match = Match(groupStage: self.id, + index: index, + format: self.matchFormat, + name: self.localizedMatchUpLabel(for: index)) + match.store = self.store +// print("_createMatch(index)", index) + return match + } + + public func removeReturnMatches(onlyLast: Bool = false) { + + var returnMatches = _matches().filter({ $0.index >= matchCount }) + if onlyLast { + let matchPhaseCount = matchPhaseCount - 1 + returnMatches = returnMatches.filter({ $0.index >= matchCount * matchPhaseCount }) + } + do { + try self.tournamentStore?.matches.delete(contentOfs: returnMatches) + } catch { + Logger.error(error) + } + } + + public var matchPhaseCount: Int { + let count = _matches().count + if matchCount > 0 { + return count / matchCount + } else { + return 0 + } + } + + public func addReturnMatches() { + var teamScores = [TeamScore]() + var matches = [Match]() + let matchPhaseCount = matchPhaseCount + for i in 0..<_numberOfMatchesToBuild() { + let newMatch = self._createMatch(index: i + matchCount * matchPhaseCount) +// let newMatch = Match(groupStage: self.id, index: i, matchFormat: self.matchFormat, name: localizedMatchUpLabel(for: i)) + teamScores.append(contentsOf: newMatch.createTeamScores()) + matches.append(newMatch) + } + + self.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + self.tournamentStore?.teamScores.addOrUpdate(contentOfs: teamScores) + } + + public func buildMatches(keepExistingMatches: Bool = false) { + var teamScores = [TeamScore]() + var matches = [Match]() + clearScoreCache() + + if keepExistingMatches == false { + _removeMatches() + + for i in 0..<_numberOfMatchesToBuild() { + let newMatch = self._createMatch(index: i) + // let newMatch = Match(groupStage: self.id, index: i, matchFormat: self.matchFormat, name: localizedMatchUpLabel(for: i)) + teamScores.append(contentsOf: newMatch.createTeamScores()) + matches.append(newMatch) + } + } else { + for match in _matches() { + match.resetTeamScores(outsideOf: []) + teamScores.append(contentsOf: match.createTeamScores()) + } + } + + do { + try self.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + try self.tournamentStore?.teamScores.addOrUpdate(contentOfs: teamScores) + } catch { + Logger.error(error) + } + } + + public func playedMatches() -> [Match] { + let ordered = _matches() + let order = _matchOrder() + let matchCount = max(1, matchCount) + let count = ordered.count / matchCount + if ordered.isEmpty == false && ordered.count % order.count == 0 { + let repeatedArray = (0.. Int { + _matchOrder()[safe: match.index] ?? match.index + } + + public func updateGroupStageState() { + clearScoreCache() + + if hasEnded(), let tournament = tournamentObject() { + + let teams = teams(true) + for (index, team) in teams.enumerated() { + team.qualified = index < tournament.qualifiedPerGroupStage + if team.bracketPosition != nil && team.qualified == false { + tournamentObject()?.shouldVerifyBracket = true + } + } + try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + if let tournamentObject = tournamentObject() { + try DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject) + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + + let groupStagesAreOverAtFirstStep = tournament.groupStagesAreOver(atStep: 0) + let nextStepGroupStages = tournament.groupStages(atStep: 1) + let groupStagesAreOverAtSecondStep = tournament.groupStagesAreOver(atStep: 1) + + if groupStagesAreOverAtFirstStep, nextStepGroupStages.isEmpty || groupStagesAreOverAtSecondStep == true, tournament.groupStageLoserBracketAreOver(), tournament.rounds().isEmpty { + tournament.endDate = Date() + DataStore.shared.tournaments.addOrUpdate(instance: tournament) + } + + tournament.updateTournamentState() + } + } + + public func scoreLabel(forGroupStagePosition groupStagePosition: Int, score: TeamGroupStageScore? = nil) -> (wins: String, losses: String, setsDifference: String?, gamesDifference: String?)? { + if let scoreData = (score ?? _score(forGroupStagePosition: groupStagePosition, nilIfEmpty: true)) { + let hideSetDifference = matchFormat.setsToWin == 1 + let setDifference = scoreData.setDifference.formatted(.number.sign(strategy: .always(includingZero: true))) + " set" + scoreData.setDifference.pluralSuffix + let gameDifference = scoreData.gameDifference.formatted(.number.sign(strategy: .always(includingZero: true))) + " jeu" + scoreData.gameDifference.localizedPluralSuffix("x") + return (wins: scoreData.wins.formatted(), losses: scoreData.loses.formatted(), setsDifference: hideSetDifference ? nil : setDifference, gamesDifference: gameDifference) +// return "\(scoreData.wins)/\(scoreData.loses) " + differenceAsString + } else { + return nil + } + } + +// func _score(forGroupStagePosition groupStagePosition: Int, nilIfEmpty: Bool = false) -> TeamGroupStageScore? { +// guard let team = teamAt(groupStagePosition: groupStagePosition) else { return nil } +// let matches = matches(forGroupStagePosition: groupStagePosition).filter({ $0.hasEnded() }) +// if matches.isEmpty && nilIfEmpty { return nil } +// let wins = matches.filter { $0.winningTeamId == team.id }.count +// let loses = matches.filter { $0.losingTeamId == team.id }.count +// let differences = matches.compactMap { $0.scoreDifference(groupStagePosition, atStep: step) } +// let setDifference = differences.map { $0.set }.reduce(0,+) +// let gameDifference = differences.map { $0.game }.reduce(0,+) +// return (team, wins, loses, setDifference, gameDifference) +// /* +// • 2 points par rencontre gagnée +// • 1 point par rencontre perdue +// • -1 point en cas de rencontre perdue par disqualification (scores de 6/0 6/0 attribués aux trois matchs) +// • -2 points en cas de rencontre perdu par WO (scores de 6/0 6/0 attribués aux trois matchs) +// */ +// } +// + public func matches(forGroupStagePosition groupStagePosition: Int) -> [Match] { + let combos = Array((0.. Date? { + guard let groupStagePosition = team.groupStagePositionAtStep(step) else { return nil } + return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate + } + + public func matchPlayed(by groupStagePosition: Int, againstPosition: Int) -> Match? { + if groupStagePosition == againstPosition { return nil } + let combos = Array((0.. [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.isRunning() == false && $0.canBeStarted(inMatches: runningMatches, checkCanPlay: checkCanPlay) }).sorted(by: \.computedStartDateForSorting) + } + + public 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) + } + + public 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 }) + } + + public 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() + } + + public func isReturnMatchEnabled() -> Bool { + _matches().count > matchCount + } + + private func _matchOrder() -> [Int] { + var order: [Int] + + switch size { + case 3: + order = [1, 2, 0] + case 4: + order = [2, 3, 1, 4, 5, 0] + case 5: + order = [3, 5, 8, 2, 6, 1, 9, 4, 7, 0] + case 6: + order = [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0] + default: + order = [] + } + + return order + } + + + func indexOf(_ matchIndex: Int) -> Int { + _matchOrder().firstIndex(of: matchIndex) ?? matchIndex + } + + func _matchUp(for matchIndex: Int) -> [Int] { + let combinations = Array((0.. String { + if matchCount > 0 { + let count = _matches().count + if count > matchCount * 2 { + return " - vague \((matchIndex / matchCount) + 1)" + } + + if matchIndex >= matchCount { + return " - retour" + } + } + + return "" + } + + func localizedMatchUpLabel(for matchIndex: Int) -> String { + let matchUp = _matchUp(for: matchIndex) + if let index = matchUp.first, let index2 = matchUp.last { + return "#\(index + 1) vs #\(index2 + 1)" + returnMatchesSuffix(for: matchIndex) + } else { + return "--" + } + } + + var matchCount: Int { + (size * (size - 1)) / 2 + } + + func team(teamPosition team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? { + let _teams = _teams(for: matchIndex) + switch team { + case .one: + return _teams.first ?? nil + case .two: + return _teams.last ?? nil + } + } + + private func _teams(for matchIndex: Int) -> [TeamRegistration?] { + let combinations = Array(0.. Int { + (size * (size - 1)) / 2 + } + + public func unsortedPlayers() -> [PlayerRegistration] { + unsortedTeams().flatMap({ $0.unsortedPlayers() }) + } + + public typealias TeamScoreAreInIncreasingOrder = (TeamGroupStageScore, TeamGroupStageScore) -> Bool + + public 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.. 1 { + let scoreA = calculateScore(for: teamPosition, matches: matches, groupStagePosition: teamPosition.groupStagePosition!) + let scoreB = calculateScore(for: otherTeam, matches: matches, groupStagePosition: otherTeam.groupStagePosition!) + + let teamsSorted = [scoreA, scoreB].sorted { (lhs, rhs) in + let predicates: [TeamScoreAreInIncreasingOrder] = [ + { $0.wins < $1.wins }, + { $0.setDifference < $1.setDifference }, + { $0.gameDifference < $1.gameDifference}, + { [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! } + ] + + for predicate in predicates { + if !predicate(lhs, rhs) && !predicate(rhs, lhs) { + continue + } + + return predicate(lhs, rhs) + } + + return false + }.map({ $0.team }) + + return teamsSorted.first == teamPosition + } else { + + if let matchIndex = combos.firstIndex(of: indexes), let match = _matches().first(where: { $0.index == matchIndex }) { + return teamPosition.id == match.losingTeamId + } else { + return false + } + + } + } + + public func unsortedTeams() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + if step > 0 { + return tournamentStore.groupStages.filter({ $0.step == step - 1 }).compactMap({ $0.teams(true)[safe: index] }) + } + return tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil } + } + + + var scoreCache: [Int: TeamGroupStageScore] = [:] + + public func computedScore(forTeam team: TeamRegistration, step: Int = 0) -> TeamGroupStageScore? { + guard let groupStagePositionAtStep = team.groupStagePositionAtStep(step) else { + return nil + } + if let cachedScore = scoreCache[groupStagePositionAtStep] { + return cachedScore + } else { + let score = _score(forGroupStagePosition: groupStagePositionAtStep) + if let score = score { + scoreCache[groupStagePositionAtStep] = score + } + return score + } + } + + public func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] { + if sortedByScore { + return unsortedTeams().compactMap({ team in + // Check cache or use provided scores, otherwise calculate and store in cache + scores?.first(where: { $0.team.id == team.id }) ?? { + return computedScore(forTeam: team, step: step) + }() + }).sorted { (lhs, rhs) in + let predicates: [TeamScoreAreInIncreasingOrder] = [ + { $0.wins < $1.wins }, + { $0.setDifference < $1.setDifference }, + { $0.gameDifference < $1.gameDifference}, + { self._headToHead($0.team, $1.team) }, + { [self] in $0.team.groupStagePositionAtStep(self.step)! > $1.team.groupStagePositionAtStep(self.step)! } + ] + + for predicate in predicates { + if !predicate(lhs, rhs) && !predicate(rhs, lhs) { + continue + } + + return predicate(lhs, rhs) + } + + return false + }.map({ $0.team }).reversed() + } else { + return unsortedTeams().sorted(by: \TeamRegistration.groupStagePosition!) + } + } + + public func _score(forGroupStagePosition groupStagePosition: Int, nilIfEmpty: Bool = false) -> TeamGroupStageScore? { + // Check if the score for this position is already cached + if let cachedScore = scoreCache[groupStagePosition] { + return cachedScore + } + + guard let team = teamAt(groupStagePosition: groupStagePosition) else { return nil } + let matches = matches(forGroupStagePosition: groupStagePosition).filter({ $0.hasEnded() }) + if matches.isEmpty && nilIfEmpty { return nil } + let score = calculateScore(for: team, matches: matches, groupStagePosition: groupStagePosition) + scoreCache[groupStagePosition] = score + return score + } + + private func calculateScore(for team: TeamRegistration, matches: [Match], groupStagePosition: Int) -> TeamGroupStageScore { + let wins = matches.filter { $0.winningTeamId == team.id }.count + let loses = matches.filter { $0.losingTeamId == team.id }.count + let differences = matches.compactMap { $0.scoreDifference(groupStagePosition, atStep: step) } + let setDifference = differences.map { $0.set }.reduce(0,+) + let gameDifference = differences.map { $0.game }.reduce(0,+) + + return (team, wins, loses, setDifference, gameDifference) + } + + // Clear the cache if necessary, for example when starting a new step or when matches update + func clearScoreCache() { + scoreCache.removeAll() + } + +// func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] { +// if sortedByScore { +// return unsortedTeams().compactMap({ team in +// scores?.first(where: { $0.team.id == team.id }) ?? _score(forGroupStagePosition: team.groupStagePositionAtStep(step)!) +// }).sorted { (lhs, rhs) in +// // Calculate intermediate values once and reuse them +// let lhsWins = lhs.wins +// let rhsWins = rhs.wins +// let lhsSetDifference = lhs.setDifference +// let rhsSetDifference = rhs.setDifference +// let lhsGameDifference = lhs.gameDifference +// let rhsGameDifference = rhs.gameDifference +// let lhsHeadToHead = self._headToHead(lhs.team, rhs.team) +// let rhsHeadToHead = self._headToHead(rhs.team, lhs.team) +// let lhsGroupStagePosition = lhs.team.groupStagePositionAtStep(self.step)! +// let rhsGroupStagePosition = rhs.team.groupStagePositionAtStep(self.step)! +// +// // Define comparison predicates in the same order +// let predicates: [(Bool, Bool)] = [ +// (lhsWins < rhsWins, lhsWins > rhsWins), +// (lhsSetDifference < rhsSetDifference, lhsSetDifference > rhsSetDifference), +// (lhsGameDifference < rhsGameDifference, lhsGameDifference > rhsGameDifference), +// (lhsHeadToHead, rhsHeadToHead), +// (lhsGroupStagePosition > rhsGroupStagePosition, lhsGroupStagePosition < rhsGroupStagePosition) +// ] +// +// // Iterate over predicates and return as soon as a valid comparison is found +// for (lhsPredicate, rhsPredicate) in predicates { +// if lhsPredicate { return true } +// if rhsPredicate { return false } +// } +// +// return false +// }.map({ $0.team }).reversed() +// } else { +// return unsortedTeams().sorted(by: \TeamRegistration.groupStagePosition!) +// } +// } + + public func updateMatchFormat(_ updatedMatchFormat: MatchFormat) { + self.matchFormat = updatedMatchFormat + self.updateAllMatchesFormat() + } + + public func updateAllMatchesFormat() { + let playedMatches = playedMatches() + playedMatches.forEach { match in + match.matchFormat = matchFormat + } + self.tournamentStore?.matches.addOrUpdate(contentOfs: playedMatches) + } + + public 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") + } + + public func finalPosition(ofTeam team: TeamRegistration) -> Int? { + guard hasEnded() else { return nil } + return teams(true).firstIndex(of: team) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let matches = self._matches() + for match in matches { + match.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + self.tournamentStore?.matches.deleteDependencies(matches, shouldBeSynchronized: shouldBeSynchronized) + } + +// init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// +// id = try container.decode(String.self, forKey: ._id) +// storeId = try container.decode(String.self, forKey: ._storeId) +// lastUpdate = try container.decode(Date.self, forKey: ._lastUpdate) +// tournament = try container.decode(String.self, forKey: ._tournament) +// index = try container.decode(Int.self, forKey: ._index) +// size = try container.decode(Int.self, forKey: ._size) +// format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) +// startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) +// name = try container.decodeIfPresent(String.self, forKey: ._name) +// step = try container.decodeIfPresent(Int.self, forKey: ._step) ?? 0 +// } +// +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// +// try container.encode(id, forKey: ._id) +// try container.encode(storeId, forKey: ._storeId) +// try container.encode(lastUpdate, forKey: ._lastUpdate) +// try container.encode(tournament, forKey: ._tournament) +// try container.encode(index, forKey: ._index) +// try container.encode(size, forKey: ._size) +// try container.encode(format, forKey: ._format) +// try container.encode(startDate, forKey: ._startDate) +// try container.encode(name, forKey: ._name) +// try container.encode(step, forKey: ._step) +// } + + func insertOnServer() { + self.tournamentStore?.groupStages.writeChangeAndInsertOnServer(instance: self) + for match in self._matches() { + match.insertOnServer() + } + } + +} + +extension GroupStage { + enum CodingKeys: String, CodingKey { + case _id = "id" + case _storeId = "storeId" + case _lastUpdate = "lastUpdate" + case _tournament = "tournament" + case _index = "index" + case _size = "size" + case _format = "format" + case _startDate = "startDate" + case _name = "name" + case _step = "step" + } +} + +extension GroupStage: Selectable { + public func selectionLabel(index: Int) -> String { + groupStageTitle() + } + + public func badgeValue() -> Int? { + return runningMatches(playedMatches: _matches()).count + } + + public func badgeValueColor() -> Color? { + return nil + } + + public func badgeImage() -> Badge? { + if teams().count < size { + return .xmark + } else { + return hasEnded() ? .checkmark : nil + } + } +} diff --git a/PadelClubData/Data/Match.swift b/PadelClubData/Data/Match.swift new file mode 100644 index 0000000..919ad8b --- /dev/null +++ b/PadelClubData/Data/Match.swift @@ -0,0 +1,1094 @@ +// +// Match.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage + +@Observable +public final class Match: BaseMatch, SideStorable { + + static func == (lhs: Match, rhs: Match) -> Bool { + lhs.id == rhs.id && lhs.startDate == rhs.startDate + } + + public 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 + + public init(round: String? = nil, groupStage: String? = nil, startDate: Date? = nil, endDate: Date? = nil, index: Int, format: MatchFormat? = nil, servingTeamId: String? = nil, winningTeamId: String? = nil, losingTeamId: String? = nil, name: String? = nil, disabled: Bool = false, courtIndex: Int? = nil, confirmed: Bool = false) { + + super.init(round: round, groupStage: groupStage, startDate: startDate, endDate: endDate, index: index, format: format, servingTeamId: servingTeamId, winningTeamId: winningTeamId, losingTeamId: losingTeamId, name: name, disabled: disabled, courtIndex: courtIndex, confirmed: confirmed) + + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public func setMatchName(_ serverName: String?) { + self.name = serverName + } + + public func isFromLastRound() -> Bool { + guard let roundObject, roundObject.parent == nil else { return false } + guard let currentTournament = currentTournament() else { return false } + if currentTournament.rounds().count - 1 == roundObject.index { + return true + } else { + return false + } + } + + public var tournamentStore: TournamentStore? { + if let id = self.store?.identifier { + return TournamentLibrary.shared.store(tournamentId: id) + } + fatalError("missing store for \(String(describing: type(of: self)))") + } + + public var courtIndexForSorting: Int { + courtIndex ?? Int.max + } + + // MARK: - Computed dependencies + + public var teamScores: [TeamScore] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamScores.filter { $0.match == self.id } + } + + // MARK: - + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let teamScores = self.teamScores + for teamScore in teamScores { + teamScore.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + self.tournamentStore?.teamScores.deleteDependencies(teamScores, shouldBeSynchronized: shouldBeSynchronized) + } + + public 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) + } + + public func matchWarningSubject() -> String { + [roundTitle(), matchTitle(.short)].compacted().joined(separator: " ") + } + + public func matchWarningMessage() -> String { + [roundAndMatchTitle(), startDate?.localizedDate(), courtName(), matchFormat.computedLongLabel].compacted().joined(separator: "\n") + } + + public 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 roundObject?.groupStageLoserBracket == true { + return "\(index)\(index.ordinalFormattedSuffix()) place" + } + if let groupStageObject { + return groupStageObject.localizedMatchUpLabel(for: index) + } + + switch displayStyle { + case .wide, .title: + return "Match \(indexInRound(in: matches) + 1)" + case .short: + return "n˚\(indexInRound(in: matches) + 1)" + } + } + + public func isSeedLocked(atTeamPosition teamPosition: TeamPosition) -> Bool { + return previousMatch(teamPosition)?.disabled == true + } + + public func unlockSeedPosition(atTeamPosition teamPosition: TeamPosition) { + previousMatch(teamPosition)?.enableMatch() + } + + @discardableResult + public func lockAndGetSeedPosition(atTeamPosition teamPosition: TeamPosition) -> Int { + let matchIndex = index + previousMatch(teamPosition)?.disableMatch() + return matchIndex * 2 + teamPosition.rawValue + } + + public 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 + } + + public func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? { + let minutesToAdd = Double(matchFormat.getEstimatedDuration(additionalEstimationDuration)) + return startDate?.addingTimeInterval(minutesToAdd * 60.0) + } + + public func winner() -> TeamRegistration? { + guard let winningTeamId else { return nil } + return self.tournamentStore?.teamRegistrations.findById(winningTeamId) + } + + public func loser() -> TeamRegistration? { + guard let losingTeamId else { return nil } + return self.tournamentStore?.teamRegistrations.findById(losingTeamId) + } + + + public func localizedStartDate() -> String { + if let startDate { + return startDate.formatted(date: .abbreviated, time: .shortened) + } else { + return "" + } + } + + public 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 + } + + public func cleanScheduleAndSave(_ targetStartDate: Date? = nil) { + startDate = targetStartDate ?? startDate + confirmed = false + endDate = nil + followingMatch()?.cleanScheduleAndSave(nil) + _loserMatch()?.cleanScheduleAndSave(nil) + self.tournamentStore?.matches.addOrUpdate(instance: self) + } + + public func resetMatch() { + losingTeamId = nil + winningTeamId = nil + endDate = nil + removeCourt() + servingTeamId = nil + groupStageObject?.updateGroupStageState() + roundObject?.updateTournamentState() + teams().forEach({ $0.resetRestingTime() }) + } + + public func resetScores() { + teamScores.forEach({ $0.score = nil }) + self.tournamentStore?.teamScores.addOrUpdate(contentOfs: teamScores) + } + + public func teamWillBeWalkOut(_ team: TeamRegistration) { + resetMatch() + let existingTeamScore = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team) + existingTeamScore.walkOut = 1 + self.tournamentStore?.teamScores.addOrUpdate(instance: existingTeamScore) + } + + public func luckyLosers() -> [TeamRegistration] { + return roundObject?.previousRound()?.losers() ?? [] + } + + public func isWalkOutSpot(_ teamPosition: TeamPosition) -> Bool { + return teamScore(teamPosition)?.walkOut == 1 + } + + public func setLuckyLoser(team: TeamRegistration, teamPosition: TeamPosition) { + resetMatch() + + let matchIndex = index + let position = matchIndex * 2 + teamPosition.rawValue + + let previousScores = teamScores.filter({ $0.luckyLoser == position }) + self.tournamentStore?.teamScores.delete(contentOfs: previousScores) + + let teamScoreLuckyLoser = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team) + teamScoreLuckyLoser.luckyLoser = position + self.tournamentStore?.teamScores.addOrUpdate(instance: teamScoreLuckyLoser) + } + + public func disableMatch() { + _toggleMatchDisableState(true) + } + + public func enableMatch() { + _toggleMatchDisableState(false) + } + + private func _loserMatch() -> Match? { + let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: index) + return roundObject?.loserRounds().first?.getMatch(atMatchIndexInRound: indexInRound / 2) + } + + public 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 { + if forwardMatch.disabled != state || forwardMatch.byeState { + forwardMatch.byeState = false + forwardMatch._toggleMatchDisableState(state, forward: true) + } + } else if byeState && next.byeState { + print("don't disable forward match") + if forwardMatch.byeState || forwardMatch.disabled { + forwardMatch.byeState = false + forwardMatch._toggleMatchDisableState(false, forward: true) + } + } else if forwardMatch.byeState == false { + forwardMatch.byeState = true + forwardMatch._toggleMatchDisableState(false, forward: true) + } else if forwardMatch.disabled != state { + 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) + } + + public func isSeeded() -> Bool { + return isSeededAt(.one) || isSeededAt(.two) + } + + public func isSeededAt(_ teamPosition: TeamPosition) -> Bool { + if let team = team(teamPosition) { + return isSeededBy(team: team) + } + return false + } + + public func _toggleMatchDisableState(_ state: Bool, forward: Bool = false, single: Bool = false) { + //if disabled == state { return } + let currentState = disabled + disabled = state + + + if disabled != currentState { + do { + try self.tournamentStore?.teamScores.delete(contentOfs: teamScores) + } catch { + Logger.error(error) + } + } + if state == true, state != currentState { + let teams = teams() + for team in teams { + if isSeededBy(team: team) { + team.bracketPosition = nil + self.tournamentStore?.teamRegistrations.addOrUpdate(instance: team) + } + } + } + //byeState = false + + if state != currentState { + roundObject?._cachedSeedInterval = nil + name = nil + do { + try self.tournamentStore?.matches.addOrUpdate(instance: self) + } catch { + Logger.error(error) + } + } + + if single == false { + _toggleLoserMatchDisableState(state) + if forward { + _toggleForwardMatchDisableState(state) + } else { + topPreviousRoundMatch()?._toggleMatchDisableState(state) + bottomPreviousRoundMatch()?._toggleMatchDisableState(state) + } + } + } + + public func next() -> Match? { + guard let tournamentStore = self.tournamentStore else { return nil } + let matches: [Match] = tournamentStore.matches.filter { $0.round == round && $0.index > index && $0.disabled == false } + return matches.sorted(by: \.index).first + } + + public func followingMatch() -> Match? { + guard let nextRoundId = roundObject?.nextRound()?.id else { return nil } + return getFollowingMatch(fromNextRoundId: nextRoundId) + } + + public func getFollowingMatch(fromNextRoundId nextRoundId: String) -> Match? { + return self.tournamentStore?.matches.first(where: { $0.round == nextRoundId && $0.index == (index - 1) / 2 }) + } + + public func getDuration() -> Int { + if let tournament = currentTournament() { + matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) + } else { + matchFormat.getEstimatedDuration() + } + } + + public func roundTitle(_ displayStyle: DisplayStyle = .wide) -> String? { + if groupStage != nil { return groupStageObject?.groupStageTitle() } + else if let roundObject { return roundObject.roundTitle() } + else { return nil } + } + + public func roundAndMatchTitle(_ displayStyle: DisplayStyle = .wide) -> String { + [roundTitle(displayStyle), matchTitle(displayStyle)].compactMap({ $0 }).joined(separator: " ") + } + + 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 + }) + } + + public func previousMatch(_ teamPosition: TeamPosition) -> Match? { + if teamPosition == .one { + return topPreviousRoundMatch() + } else { + return bottomPreviousRoundMatch() + } + } + + public func loserMatches() -> [Match] { + guard let roundObject else { return [] } + return [roundObject.upperBracketTopMatch(ofMatchIndex: index, previousRound: nil), roundObject.upperBracketBottomMatch(ofMatchIndex: index, previousRound: nil)].compactMap({ $0 }) + } + + public func loserMatch(_ teamPosition: TeamPosition) -> Match? { + if teamPosition == .one { + return roundObject?.upperBracketTopMatch(ofMatchIndex: index, previousRound: nil) + } else { + return roundObject?.upperBracketBottomMatch(ofMatchIndex: index, previousRound: nil) + } + + } + + public var computedOrder: Int { + if let groupStageObject { + return (groupStageObject.index + 1) * 100 + groupStageObject.indexOf(index) + } + guard let roundObject else { return index } + return (300 - (roundObject.theoryCumulativeMatchCount * 10 + roundObject.index * 22)) * 10 + indexInRound() + } + + func previousMatches() -> [Match] { + guard let roundObject else { return [] } + guard let tournamentStore = self.tournamentStore else { return [] } + + let roundObjectPreviousRoundId = roundObject.previousRound()?.id + return tournamentStore.matches.filter { match in + match.round == roundObjectPreviousRoundId && (match.index == topPreviousRoundMatchIndex() || match.index == bottomPreviousRoundMatchIndex()) + }.sorted(by: \.index) + } + + public var matchFormat: MatchFormat { + get { + format ?? .defaultFormatForMatchType(.groupStage) + } + set { + format = newValue + } + } + + public func removeWalkOut() { + teamScores.forEach { teamScore in + teamScore.walkOut = nil + teamScore.score = nil + } + tournamentStore?.teamScores.addOrUpdate(contentOfs: teamScores) + endDate = nil + winningTeamId = nil + losingTeamId = nil + groupStageObject?.updateGroupStageState() + roundObject?.updateTournamentState() + updateFollowingMatchTeamScore() + } + + public func setWalkOut(_ teamPosition: TeamPosition) { + let teamScoreWalkout = teamScore(teamPosition) ?? TeamScore(match: id, team: team(teamPosition)) + teamScoreWalkout.walkOut = 0 + teamScoreWalkout.score = matchFormat.defaultWalkOutScore(true).compactMap({ String($0) }).joined(separator: ",") + let teamScoreWinning = teamScore(teamPosition.otherTeam) ?? TeamScore(match: id, team: team(teamPosition.otherTeam)) + teamScoreWinning.walkOut = nil + teamScoreWinning.score = matchFormat.defaultWalkOutScore(false).compactMap({ String($0) }).joined(separator: ",") + do { + try self.tournamentStore?.teamScores.addOrUpdate(contentOfs: [teamScoreWalkout, teamScoreWinning]) + } catch { + Logger.error(error) + } + + if endDate == nil { + endDate = Date() + } + teams().forEach({ $0.resetRestingTime() }) + winningTeamId = teamScoreWinning.teamRegistration + losingTeamId = teamScoreWalkout.teamRegistration + groupStageObject?.updateGroupStageState() + roundObject?.updateTournamentState() + updateFollowingMatchTeamScore() + } + + public func setUnfinishedScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { + updateScore(fromMatchDescriptor: matchDescriptor) + if endDate == nil { + endDate = Date() + } + if startDate == nil { + startDate = endDate?.addingTimeInterval(Double(-getDuration()*60)) + } else if let startDate, let endDate, startDate >= endDate { + self.startDate = endDate.addingTimeInterval(Double(-getDuration()*60)) + } + confirmed = true + } + + public func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { + updateScore(fromMatchDescriptor: matchDescriptor) + if endDate == nil { + endDate = Date() + } + if startDate == nil { + startDate = endDate?.addingTimeInterval(Double(-getDuration()*60)) + } else if let startDate, let endDate, startDate >= endDate { + self.startDate = endDate.addingTimeInterval(Double(-getDuration()*60)) + } + + let teamOne = team(matchDescriptor.winner) + let teamTwo = team(matchDescriptor.winner.otherTeam) + + teamOne?.hasArrived() + teamTwo?.hasArrived() + teamOne?.resetRestingTime() + teamTwo?.resetRestingTime() + + winningTeamId = teamOne?.id + losingTeamId = teamTwo?.id + + confirmed = true + + groupStageObject?.updateGroupStageState() + roundObject?.updateTournamentState() + if let tournament = currentTournament(), let endDate, let startDate { + if endDate.isEarlierThan(tournament.startDate) { + tournament.startDate = startDate + } + do { + try DataStore.shared.tournaments.addOrUpdate(instance: tournament) + } catch { + Logger.error(error) + } + } + updateFollowingMatchTeamScore() + } + + public 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: ",") + + if matchDescriptor.teamOneScores.last?.contains("-") == true && matchDescriptor.teamTwoScores.last?.contains("-") == false { + teamScoreTwo.score = (matchDescriptor.teamTwoScores.dropLast() + [matchDescriptor.teamTwoScores.last! + "-0"]).joined(separator: ",") + } else if matchDescriptor.teamTwoScores.last?.contains("-") == true && matchDescriptor.teamOneScores.last?.contains("-") == false { + teamScoreOne.score = (matchDescriptor.teamOneScores.dropLast() + [matchDescriptor.teamOneScores.last! + "-0"]).joined(separator: ",") + } + + do { + try self.tournamentStore?.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo]) + } catch { + Logger.error(error) + } + matchFormat = matchDescriptor.matchFormat + } + + public func updateFollowingMatchTeamScore() { + followingMatch()?.updateTeamScores() + _loserMatch()?.updateTeamScores() + } + + public func resetTeamScores(outsideOf newTeamScores: [TeamScore]) { + let ids = newTeamScores.map { $0.id } + let teamScores = teamScores.filter({ ids.contains($0.id) == false }) + if teamScores.isEmpty == false { + self.tournamentStore?.teamScores.delete(contentOfs: teamScores) + 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 + } + + public 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 + } + + public func updateTeamScores() { + let teams = getOrCreateTeamScores() + + self.tournamentStore?.teamScores.addOrUpdate(contentOfs: teams) + resetTeamScores(outsideOf: teams) + if teams.isEmpty == false { + updateFollowingMatchTeamScore() + } + } + + public func validateMatch(fromStartDate: Date, toEndDate: Date, fieldSetup: MatchFieldSetup, forced: Bool = false) { + if hasEnded() == false { + startDate = fromStartDate + + switch fieldSetup { + case .fullRandom: + if let _courtIndex = allCourts().randomElement() { + setCourt(_courtIndex) + } + case .random: + let runningMatches: [Match] = DataStore.shared.runningMatches() + if let _courtIndex = availableCourts(runningMatches: runningMatches).randomElement() { + setCourt(_courtIndex) + } + case .field(let _courtIndex): + setCourt(_courtIndex) + } + + } else { + startDate = fromStartDate + endDate = toEndDate + } + + if let startDate, startDate.timeIntervalSinceNow <= 300 { + confirmed = true + } else { + confirmed = false + } + } + + public func courtName() -> String? { + guard let courtIndex else { return nil } + if let courtName = currentTournament()?.courtName(atIndex: courtIndex) { + return courtName + } else { + return Court.courtIndexedTitle(atIndex: courtIndex) + } + } + + public func courtName(for selectedIndex: Int) -> String { + if let courtName = currentTournament()?.courtName(atIndex: selectedIndex) { + return courtName + } else { + return Court.courtIndexedTitle(atIndex: selectedIndex) + } + } + + public func courtCount() -> Int { + return currentTournament()?.courtCount ?? 1 + } + + func courtIsAvailable(_ courtIndex: Int, in runningMatches: [Match]) -> Bool { + let courtUsed = currentTournament()?.courtUsed(runningMatches: runningMatches) ?? [] + return courtUsed.contains(courtIndex) == false + } + + func courtIsPreferred(_ courtIndex: Int) -> Bool { + return false + } + + func allCourts() -> [Int] { + let availableCourts = Array(0.. [Int] { + let courtUsed = currentTournament()?.courtUsed(runningMatches: runningMatches) ?? [] + return Set(allCourts().map { $0 }).subtracting(Set(courtUsed)).sorted() + } + + public func removeCourt() { + courtIndex = nil + } + + public func setCourt(_ courtIndex: Int) { + self.courtIndex = courtIndex + } + + public func canBeStarted(inMatches matches: [Match], checkCanPlay: Bool) -> Bool { + let teams = teamScores + guard teams.count == 2 else { + //print("teams.count != 2") + return false + } + guard hasEnded() == false else { return false } + guard hasStarted() == false else { return false } + return teams.compactMap({ $0.team }).allSatisfy({ + ((checkCanPlay && $0.canPlay()) || checkCanPlay == false) && isTeamPlaying($0, inMatches: matches) == false + }) + } + + public func isTeamPlaying(_ team: TeamRegistration, inMatches matches: [Match]) -> Bool { + return matches.filter({ $0.teamScores.compactMap { $0.teamRegistration }.contains(team.id) }).isEmpty == false + } + + public var computedStartDateForSorting: Date { + return startDate ?? .distantFuture + } + + var computedEndDateForSorting: Date { + return endDate ?? .distantFuture + } + + public func hasSpaceLeft() -> Bool { + return teamScores.count < 2 + } + + public func isReady() -> Bool { + return teamScores.count >= 2 +// teams().count == 2 + } + + public func isEmpty() -> Bool { + return teamScores.isEmpty +// teams().isEmpty + } + + public func hasEnded() -> Bool { + return endDate != nil + } + + public func isGroupStage() -> Bool { + return groupStage != nil + } + + public 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 } + } + + public func hasWalkoutTeam() -> Bool { + return walkoutTeam().isEmpty == false + } + + public func currentTournament() -> Tournament? { + return groupStageObject?.tournamentObject() ?? roundObject?.tournamentObject() + } + + func tournamentId() -> String? { + return groupStageObject?.tournament ?? roundObject?.tournament + } + + public func scores() -> [TeamScore] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamScores.filter { $0.match == id } + } + + public 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, atStep step: Int) -> (set: Int, game: Int)? { + guard let teamScoreTeam = teamScore(.one), let teamScoreOtherTeam = teamScore(.two) else { return nil } + var reverseValue = 1 + if teamPosition == team(.two)?.groupStagePositionAtStep(step) { + reverseValue = -1 + } + let endedSetsOne = teamScoreTeam.score?.components(separatedBy: ",").compactMap({ $0.components(separatedBy: "-").first }).compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreTeam.isWalkOut()) + let endedSetsTwo = teamScoreOtherTeam.score?.components(separatedBy: ",").compactMap({ $0.components(separatedBy: "-").first }).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 + } + + // si 3 sets et 3eme set super tie break, different des 2 premiers sets, alors super tie points ne sont pas des jeux et doivent etre compté comme un jeu + + if matchFormat.canSuperTie, endedSetsOne.count == 3 { + let games = zip.map { ($0, $1) } + let gameDifference = games.enumerated().map({ index, pair in + if index < 2 { + return pair.0 - pair.1 + } else { + return pair.0 < pair.1 ? -1 : 1 + } + }) + .reduce(0,+) + return (setDifference * reverseValue, gameDifference * reverseValue) + } else { + 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) + } + + public func teamWon(_ team: TeamRegistration?) -> Bool { + guard let winningTeamId else { return false } + return winningTeamId == team?.id + } + + public func teamWon(atPosition teamPosition: TeamPosition) -> Bool { + guard let winningTeamId else { return false } + return winningTeamId == team(teamPosition)?.id + } + + public 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) + } + } + + public func teamNames(_ team: TeamRegistration?) -> [String]? { + return team?.players().map { $0.playerLabel() } + } + + public func teamWalkOut(_ team: TeamRegistration?) -> Bool { + return teamScore(ofTeam: team)?.isWalkOut() == true + } + + public func teamScore(_ team: TeamPosition) -> TeamScore? { + return teamScore(ofTeam: self.team(team)) + } + + public func teamScore(ofTeam team: TeamRegistration?) -> TeamScore? { + return scores().first(where: { $0.teamRegistration == team?.id }) + } + + public func isRunning() -> Bool { // at least a match has started + return confirmed && hasStarted() && hasEnded() == false + } + + public func hasStarted() -> Bool { // meaning at least one match is over + if let startDate { + return startDate.timeIntervalSinceNow < 0 && confirmed + } + if hasEnded() { + return true + } + return false + + //todo scores +// if let score { +// return score.hasEnded == false && score.sets.isEmpty == false +// } else { +// return false +// } + } + + public var roundObject: Round? { + guard let round else { return nil } + return self.tournamentStore?.rounds.findById(round) + } + + public var groupStageObject: GroupStage? { + guard let groupStage else { return nil } + return self.tournamentStore?.groupStages.findById(groupStage) + } + + public var isLoserBracket: Bool { + if let roundObject { + if roundObject.parent != nil || roundObject.groupStageLoserBracket { + return true + } + } + return false + } + + public var matchType: MatchType { + if isLoserBracket { + return .loserBracket + } else if isGroupStage() { + return .groupStage + } else { + return .bracket + } + } + + public var restingTimeForSorting: TimeInterval { + (teams().compactMap({ $0.restingTime() }).max() ?? .distantFuture).timeIntervalSinceNow + } + + public func isValidSpot() -> Bool { + previousMatches().allSatisfy({ $0.isSeeded() == false }) + } + + public func expectedToBeRunning() -> Bool { + guard let startDate else { return false } + return confirmed == false && startDate.timeIntervalSinceNow < 0 + } + + public func expectedFormattedStartDate(canBePlayedInSpecifiedCourt: Bool, availableCourts: [Int], estimatedStartDate: CourtIndexAndDate?, updatedField: Int?) -> String { + guard let startDate else { return "" } + guard hasEnded() == false, isRunning() == false else { return "" } + let depthReadiness = depthReadiness() + if depthReadiness == 0 { + if canBePlayedInSpecifiedCourt { + return "possible tout de suite" + } else if let updatedField, availableCourts.contains(updatedField) { + return "possible tout de suite \(courtName(for: updatedField))" + } else if let first = availableCourts.first { + return "possible tout de suite \(courtName(for: first))" + } else if let estimatedStartDate { + return "dans ~" + estimatedStartDate.1.timeElapsedString() + " " + courtName(for: estimatedStartDate.0) + } + return "était prévu à " + startDate.formattedAsHourMinute() + } else if depthReadiness == 1 { + return "possible prochaine rotation" + } else { + return "dans \(depthReadiness) rotation\(depthReadiness.pluralSuffix), ~\((getDuration() * depthReadiness).durationInHourMinutes())" + } + } + + public func runningDuration() -> String { + guard let startDate else { return "" } + return " depuis " + startDate.timeElapsedString() + } + + public func canBePlayedInSpecifiedCourt(runningMatches: [Match]) -> Bool { + guard let courtIndex else { return false } + if expectedToBeRunning() { + return courtIsAvailable(courtIndex, in: runningMatches) + } else { + return true + } + } + + public typealias CourtIndexAndDate = (courtIndex: Int, startDate: Date) + + func nextCourtsAvailable(availableCourts: [Int], runningMatches: [Match]) -> [CourtIndexAndDate] { + guard let tournament = currentTournament() else { return [] } + let startDate = Date().withoutSeconds() + if runningMatches.isEmpty { + return availableCourts.map { + ($0, startDate) + } + } + + let optionalDates : [CourtIndexAndDate?] = runningMatches.map({ match in + guard let endDate = match.estimatedEndDate(tournament.additionalEstimationDuration) else { return nil } + guard let courtIndex = match.courtIndex else { return nil } + if endDate <= startDate { + return (courtIndex, startDate.addingTimeInterval(600)) + } else { + return (courtIndex, endDate) + } + }) + + let dates : [CourtIndexAndDate] = optionalDates.compacted().sorted { a, b in + a.1 < b.1 + } + return dates + } + + public func estimatedStartDate(availableCourts: [Int], runningMatches: [Match]) -> CourtIndexAndDate? { + guard isReady() else { return nil } + guard let tournament = currentTournament() else { return nil } + let availableCourts = nextCourtsAvailable(availableCourts: availableCourts, runningMatches: runningMatches) + return availableCourts.first(where: { (courtIndex, startDate) in + let endDate = startDate.addingTimeInterval(TimeInterval(matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration)) * 60) + if tournament.courtUnavailable(courtIndex: courtIndex, from: startDate, to: endDate) == false { + return true + } + + return false + }) + } + + public func depthReadiness() -> Int { + // Base case: If this match is ready, the depth is 0 + if isReady() { + return 0 + } + + // Recursive case: If not ready, check the maximum depth of readiness among previous matches + // If previousMatches() is empty, return a default depth of -1 + let previousDepth = ancestors().map { $0.depthReadiness() }.max() ?? -1 + return previousDepth + 1 + } + + public func ancestors() -> [Match] { + previousMatches() + loserMatches() + } + + public func matchSpots() -> [MatchSpot] { + [MatchSpot(match: self, teamPosition: .one), MatchSpot(match: self, teamPosition: .two)] + } + + func insertOnServer() { + self.tournamentStore?.matches.writeChangeAndInsertOnServer(instance: self) + for teamScore in self.teamScores { + teamScore.insertOnServer() + } + } + +} + +public enum MatchDateSetup: Hashable, Identifiable { + case inMinutes(Int) + case now + case customDate + case previousRotation + case nextRotation + + public var id: Int { hashValue } +} + +public enum MatchFieldSetup: Hashable, Identifiable { + case random + case fullRandom +// case firstAvailable + case field(Int) + + public var courtIndex: Int? { + switch self { + case .random: + return nil + case .fullRandom: + return nil + case .field(let int): + return int + } + } + + public var id: Int { hashValue } +} diff --git a/PadelClubData/Data/MatchScheduler.swift b/PadelClubData/Data/MatchScheduler.swift new file mode 100644 index 0000000..a1f6cb7 --- /dev/null +++ b/PadelClubData/Data/MatchScheduler.swift @@ -0,0 +1,888 @@ +// +// MatchScheduler.swift +// PadelClub +// +// Created by Razmig Sarkissian on 08/04/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class MatchScheduler: BaseMatchScheduler, SideStorable { +// +// init(tournament: String, +// timeDifferenceLimit: Int = 5, +// loserBracketRotationDifference: Int = 0, +// upperBracketRotationDifference: Int = 1, +// accountUpperBracketBreakTime: Bool = true, +// accountLoserBracketBreakTime: Bool = false, +// randomizeCourts: Bool = true, +// rotationDifferenceIsImportant: Bool = false, +// shouldHandleUpperRoundSlice: Bool = false, +// shouldEndRoundBeforeStartingNext: Bool = true, +//<<<<<<< HEAD +// groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) { +// super.init() +//======= +// groupStageChunkCount: Int? = nil, +// overrideCourtsUnavailability: Bool = false, +// shouldTryToFillUpCourtsAvailable: Bool = true, +// courtsAvailable: Set = Set(), +// simultaneousStart: Bool = true) { +//>>>>>>> main +// self.tournament = tournament +// self.timeDifferenceLimit = timeDifferenceLimit +// self.loserBracketRotationDifference = loserBracketRotationDifference +// self.upperBracketRotationDifference = upperBracketRotationDifference +// self.accountUpperBracketBreakTime = accountUpperBracketBreakTime +// self.accountLoserBracketBreakTime = accountLoserBracketBreakTime +// self.randomizeCourts = randomizeCourts +// self.rotationDifferenceIsImportant = rotationDifferenceIsImportant +// self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice +// self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext +// self.groupStageChunkCount = groupStageChunkCount +// self.overrideCourtsUnavailability = overrideCourtsUnavailability +// self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable +// self.courtsAvailable = courtsAvailable +// self.simultaneousStart = simultaneousStart +// } + + 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 TournamentLibrary.shared.store(tournamentId: self.tournament) +// TournamentStore.instance(tournamentId: self.tournament) + } + + func tournamentObject() -> Tournament? { + return Store.main.findById(tournament) + } + + @discardableResult + public func updateGroupStageSchedule(tournament: Tournament, specificGroupStage: GroupStage? = nil, atStep step: Int = 0, startDate: Date? = nil) -> Date { + let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue() + var groupStages: [GroupStage] = tournament.groupStages(atStep: step) + if let specificGroupStage { + groupStages = [specificGroupStage] + } + + let matches = groupStages.flatMap { $0._matches() } + matches.forEach({ + $0.removeCourt() + $0.startDate = nil + $0.confirmed = false + }) + + var lastDate : Date = startDate ?? tournament.startDate + let times = Set(groupStages.compactMap { $0.startDate }).sorted() + + if let first = times.first { + if first.isEarlierThan(tournament.startDate) { + tournament.startDate = first + do { + try DataStore.shared.tournaments.addOrUpdate(instance: tournament) + } catch { + Logger.error(error) + } + } + } + + times.forEach({ time in + if lastDate.isEarlierThan(time) { + lastDate = time + } + let groups = groupStages.filter({ $0.startDate == time }) + let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate) + + dispatch.timedMatches.forEach { matchSchedule in + if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { + let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) + let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 + if let startDate = match.groupStageObject?.startDate { + let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) + match.startDate = matchStartDate + lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) + } + match.setCourt(matchSchedule.courtIndex) + } + } + }) + + groupStages.filter({ $0.startDate == nil || times.contains($0.startDate!) == false }).chunked(into: computedGroupStageChunkCount).forEach { groups in + groups.forEach({ $0.startDate = lastDate }) + do { + try self.tournamentStore?.groupStages.addOrUpdate(contentOfs: groups) + } catch { + Logger.error(error) + } + + let dispatch = groupStageDispatcher(groupStages: groups, startingDate: lastDate) + + dispatch.timedMatches.forEach { matchSchedule in + if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { + let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) + let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 + if let startDate = match.groupStageObject?.startDate { + let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) + match.startDate = matchStartDate + lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) + } + match.setCourt(matchSchedule.courtIndex) + } + } + } + do { + try self.tournamentStore?.matches.addOrUpdate(contentOfs: matches) + } catch { + Logger.error(error) + } + return lastDate + } + + func groupStageDispatcher(groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher { + + let _groupStages = groupStages + + // Get the maximum count of matches in any group + let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 + var flattenedMatches = [Match]() + if simultaneousStart { + // Flatten matches in a round-robin order by cycling through each group + flattenedMatches = (0.. 0, simultaneousStart == false { + 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 + } + }) + } + + courtsAvailable.forEach { courtIndex in + print("Checking availability for court \(courtIndex) in rotation \(rotationIndex)") + if let first = rotationMatches.first(where: { match in + let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration) + let timeIntervalToAdd = Double(rotationIndex) * Double(estimatedDuration) * 60 + let rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd) + + let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability) + + if courtsUnavailable.contains(courtIndex) { + print("Court \(courtIndex) is unavailable at \(rotationStartDate)") + return false + } + + let teamsAvailable = teamsPerRotation[rotationIndex]!.allSatisfy({ !match.containsTeamIndex($0) }) + if !teamsAvailable { + print("Teams from match \(match.roundAndMatchTitle()) are already scheduled in this rotation") + return false + } + + print("Match \(match.roundAndMatchTitle()) is available for court \(courtIndex) at \(rotationStartDate)") + return true + }) { + let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index) + + print("Scheduled match: \(first.roundAndMatchTitle()) on court \(courtIndex) at rotation \(rotationIndex)") + + slots.append(timeMatch) + teamsPerRotation[rotationIndex]!.append(contentsOf: first.matchUp()) + rotationMatches.removeAll(where: { $0.id == first.id }) + availableMatches.removeAll(where: { $0.id == first.id }) + + if let index = first.groupStageObject?.index { + groupLastRotation[index] = rotationIndex + } + } else { + print("No available matches for court \(courtIndex) in rotation \(rotationIndex), adding to free court list") + freeCourtPerRotation[rotationIndex]!.append(courtIndex) + } + } + + rotationIndex += 1 + } + + print("All matches scheduled. Total rotations: \(rotationIndex)") + + // Organize slots and ensure courts are randomized or sorted + var organizedSlots = [GroupStageTimeMatch]() + for i in 0.. Int { + if loserBracket { + return loserBracketRotationDifference + } else { + return upperBracketRotationDifference + } + } + + func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool { + print("Evaluating match: \(match.roundAndMatchTitle()) in round: \(roundObject.roundTitle()) with index: \(match.index)") + + if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate { + print("Cannot start at \(targetedStartDate), earlier than round start date \(roundStartDate)") + if targetedStartDate == minimumTargetedEndDate { + print("Updating minimumTargetedEndDate to roundStartDate: \(roundStartDate)") + minimumTargetedEndDate = roundStartDate + } else { + print("Setting minimumTargetedEndDate to the earlier of \(roundStartDate) and \(minimumTargetedEndDate)") + minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) + } + print("Returning false: Match cannot start earlier than the round start date.") + return false + } + + let previousMatches = roundObject.precedentMatches(ofMatch: match) + if previousMatches.isEmpty { + print("No ancestors matches for this match, returning true. (eg beginning of tournament 1st bracket") + return true + } + + let previousMatchSlots = slots.filter { previousMatches.map { $0.id }.contains($0.matchID) } + + if previousMatchSlots.isEmpty { + if previousMatches.filter({ !$0.disabled }).allSatisfy({ $0.startDate != nil }) { + print("All previous matches have start dates, returning true.") + return true + } + print("Some previous matches are pending, returning false.") + return false + } + + if previousMatches.filter({ !$0.disabled }).count > previousMatchSlots.count { + if previousMatches.filter({ !$0.disabled }).anySatisfy({ $0.startDate != nil }) { + print("Some previous matches started, returning true.") + return true + } + print("Not enough previous matches have started, returning false.") + return false + } + + var includeBreakTime = false + if accountLoserBracketBreakTime && roundObject.isLoserBracket() { + includeBreakTime = true + print("Including break time for loser bracket.") + } + + if accountUpperBracketBreakTime && !roundObject.isLoserBracket() { + includeBreakTime = true + print("Including break time for upper bracket.") + } + + let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy { + $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex + } + + if previousMatchIsInPreviousRotation { + print("All previous matches are from earlier rotations, returning true.") + } else { + print("Some previous matches are from the current rotation.") + } + + guard let minimumPossibleEndDate = previousMatchSlots.map({ + $0.estimatedEndDate(includeBreakTime: includeBreakTime) + }).max() else { + print("No valid previous match end date, returning \(previousMatchIsInPreviousRotation).") + return previousMatchIsInPreviousRotation + } + + if targetedStartDate >= minimumPossibleEndDate { + if rotationDifferenceIsImportant { + print("Targeted start date is after the minimum possible end date and rotation difference is important, returning \(previousMatchIsInPreviousRotation).") + return previousMatchIsInPreviousRotation + } else { + print("Targeted start date is after the minimum possible end date, returning true.") + return true + } + } else { + if targetedStartDate == minimumTargetedEndDate { + print("Updating minimumTargetedEndDate to minimumPossibleEndDate: \(minimumPossibleEndDate)") + minimumTargetedEndDate = minimumPossibleEndDate + } else { + print("Setting minimumTargetedEndDate to the earlier of \(minimumPossibleEndDate) and \(minimumTargetedEndDate)") + minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) + } + print("Targeted start date \(targetedStartDate) is before the minimum possible end date, returning false. \(minimumTargetedEndDate)") + return false + } + } + + + func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? { + slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min() + } + + func getNextEarliestAvailableDate(from slots: [TimeMatch]) -> [(Int, Date)] { + let byCourt = Dictionary(grouping: slots, by: { $0.courtIndex }) + return (byCourt.keys.flatMap { courtIndex in + let matchesByCourt = byCourt[courtIndex]?.sorted(by: \.startDate) + let lastMatch = matchesByCourt?.last + var results = [(Int, Date)]() + if let courtFreeDate = lastMatch?.estimatedEndDate(includeBreakTime: false) { + results.append((courtIndex, courtFreeDate)) + } + return results + } + ) + } + + func getAvailableCourts(from matches: [Match]) -> [(Int, Date)] { + let validMatches = matches.filter({ $0.courtIndex != nil && $0.startDate != nil }) + let byCourt = Dictionary(grouping: validMatches, by: { $0.courtIndex! }) + return (byCourt.keys.flatMap { court in + let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!) + let lastMatch = matchesByCourt?.last + var results = [(Int, Date)]() + if let courtFreeDate = lastMatch?.estimatedEndDate(additionalEstimationDuration) { + results.append((court, courtFreeDate)) + } + return results + } + ) + } + + func roundDispatcher(flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { + var slots = [TimeMatch]() + var _startDate: Date? + var rotationIndex = 0 + var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) + let courtsUnavailability = courtsUnavailability + var issueFound: Bool = false + + // Log start of the function + print("Starting roundDispatcher with \(availableMatchs.count) matches and \(courtsAvailable) courts available") + + flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in + if _startDate == nil { + _startDate = match.startDate + } else if match.startDate! > _startDate! { + _startDate = match.startDate + rotationIndex += 1 + } + + let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime) + slots.append(timeMatch) + } + + if !slots.isEmpty { + rotationIndex += 1 + } + + var freeCourtPerRotation = [Int: [Int]]() + var courts = initialCourts ?? Array(courtsAvailable) + var shouldStartAtDispatcherDate = rotationIndex > 0 + var suitableDate: Date? + + while !availableMatchs.isEmpty && !issueFound && rotationIndex < 50 { + freeCourtPerRotation[rotationIndex] = [] + let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 }) + + var rotationStartDate: Date + if previousRotationSlots.isEmpty && rotationIndex > 0 { + let computedSuitableDate = slots.sorted(by: \.computedEndDateForSorting).last?.computedEndDateForSorting + print("Previous rotation was empty, find a suitable rotationStartDate \(suitableDate)") + rotationStartDate = suitableDate ?? computedSuitableDate ?? dispatcherStartDate + } else { + rotationStartDate = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate + } + + if shouldStartAtDispatcherDate { + rotationStartDate = dispatcherStartDate + shouldStartAtDispatcherDate = false + } else { + courts = rotationIndex == 0 ? courts : Array(courtsAvailable) + } + courts.sort() + + // Log courts availability and start date + print("Courts available at rotation \(rotationIndex): \(courts)") + print("Rotation start date: \(rotationStartDate)") + + // Check for court availability and break time conflicts + if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], !freeCourtPreviousRotation.isEmpty { + print("Handling break time conflicts or waiting for free courts") + let previousPreviousRotationSlots = slots.filter { $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) } + var previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime) + var previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) + + if let courtsUnavailability, previousEndDate != nil { + previousEndDate = getFirstFreeCourt(startDate: previousEndDate!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate + } + + if let courtsUnavailability, previousEndDateNoBreak != nil { + previousEndDateNoBreak = getFirstFreeCourt(startDate: previousEndDateNoBreak!, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability).earliestFreeDate + } + + let noBreakAlreadyTested = previousRotationSlots.anySatisfy { $0.startDate == previousEndDateNoBreak } + + if let previousEndDate, let previousEndDateNoBreak { + let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate) + let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak) + print("Difference with break: \(differenceWithBreak), without break: \(differenceWithoutBreak)") + + let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60) + var difference = differenceWithBreak + + if differenceWithBreak <= 0, accountUpperBracketBreakTime == false { + difference = differenceWithoutBreak + } else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds { + difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak) + } + + print("Final difference to evaluate: \(difference)") + + if (difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate) || difference < 0 { + print(""" + Adjusting rotation start: + - Initial rotationStartDate: \(rotationStartDate) + - Adjusted by difference: \(difference) + - Adjusted rotationStartDate: \(rotationStartDate.addingTimeInterval(-difference)) + - PreviousEndDate: \(previousEndDate) + """) + + courts.removeAll(where: { freeCourtPreviousRotation.contains($0) }) + freeCourtPerRotation[rotationIndex] = courts + courts = freeCourtPreviousRotation + rotationStartDate = rotationStartDate.addingTimeInterval(-difference) + } + } + } else if let firstMatch = availableMatchs.first { + let duration = firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration) + let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) + + if Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable))).isEmpty { + print("Issue: All courts unavailable in this rotation") + if let courtsUnavailability { + let computedStartDateAndCourts = getFirstFreeCourt(startDate: rotationStartDate, duration: duration, courts: courts, courtsUnavailability: courtsUnavailability) + rotationStartDate = computedStartDateAndCourts.earliestFreeDate + courts = computedStartDateAndCourts.availableCourts + } else { + issueFound = true + } + } else { + courts = Array(Set(courtsAvailable).subtracting(Set(courtsUnavailable))) + } + } + + // Dispatch courts and schedule matches + suitableDate = dispatchCourts(courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) + rotationIndex += 1 + } + + // Organize matches in slots + var organizedSlots = [TimeMatch]() + for i in 0.. Date { + var matchPerRound = [String: Int]() + var minimumTargetedEndDate = rotationStartDate + + // Log dispatch attempt + print("Dispatching courts for rotation \(rotationIndex) with start date \(rotationStartDate) and available courts \(courts.sorted())") + + for (courtPosition, courtIndex) in courts.sorted().enumerated() { + if let firstMatch = availableMatchs.first(where: { match in + print("Trying to find a match for court \(courtIndex) in rotation \(rotationIndex)") + + let roundObject = match.roundObject! + let duration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration) + + let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) + + if courtsUnavailable.contains(courtIndex) { + print("Returning false: Court \(courtIndex) unavailable due to schedule conflicts during \(rotationStartDate).") + return false + } + + let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) + + if !canBePlayed { + print("Returning false: Match \(match.roundAndMatchTitle()) can't be played due to constraints.") + return false + } + + let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0 + let roundMatchesCount = roundObject.playedMatches().count + + if shouldHandleUpperRoundSlice { + if roundObject.parent == nil, roundObject.index > 1, currentRotationSameRoundMatches == 0, courts.count - matchPerRound.count < 2 { + return false + } + else if roundObject.parent == nil, roundObject.index == 0, matchPerRound.isEmpty == false { + return false + } + else if roundObject.parent == nil, roundObject.index == 1, currentRotationSameRoundMatches >= 0, courtsAvailable.count > 0 { + return true + } + else if roundObject.parent == nil && roundMatchesCount > courts.count && currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { + print("Returning false: Too many matches already played in the current rotation for round \(roundObject.roundTitle()).") + return false + } + } + + let indexInRound = match.indexInRound() + + + if shouldTryToFillUpCourtsAvailable == false { + if roundObject.parent == nil && roundObject.index > 1 && indexInRound == 0, let nextMatch = match.next() { + + var nextMinimumTargetedEndDate = minimumTargetedEndDate + if courtPosition < courts.count - 1 && canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &nextMinimumTargetedEndDate) { + print("Returning true: Both current \(match.index) and next match \(nextMatch.index) can be played in rotation \(rotationIndex).") + return true + } else { + print("Returning false: Either current match or next match cannot be played in rotation \(rotationIndex).") + return false + } + } + } + + print("Returning true: Match \(match.roundAndMatchTitle()) can be played on court \(courtIndex).") + return canBePlayed + }) { + print("Found match: \(firstMatch.roundAndMatchTitle()) for court \(courtIndex) at \(rotationStartDate)") + + matchPerRound[firstMatch.roundObject!.id, default: 0] += 1 + + let timeMatch = TimeMatch( + matchID: firstMatch.id, + rotationIndex: rotationIndex, + courtIndex: courtIndex, + startDate: rotationStartDate, + durationLeft: firstMatch.matchFormat.getEstimatedDuration(additionalEstimationDuration), + minimumBreakTime: firstMatch.matchFormat.breakTime.breakTime + ) + + slots.append(timeMatch) + availableMatchs.removeAll(where: { $0.id == firstMatch.id }) + } else { + print("No suitable match found for court \(courtIndex) in rotation \(rotationIndex). Adding court to freeCourtPerRotation.") + freeCourtPerRotation[rotationIndex]?.append(courtIndex) + } + + } + + if freeCourtPerRotation[rotationIndex]?.count == courtsAvailable.count { + print("All courts in rotation \(rotationIndex) are free, minimumTargetedEndDate : \(minimumTargetedEndDate)") + } + + if let courtsUnavailability { + let computedStartDateAndCourts = getFirstFreeCourt(startDate: minimumTargetedEndDate, duration: 0, courts: courts, courtsUnavailability: courtsUnavailability) + return computedStartDateAndCourts.earliestFreeDate + } + + return minimumTargetedEndDate + } + + @discardableResult public func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool { + + let upperRounds: [Round] = tournament.rounds() + let allMatches: [Match] = tournament.allMatches().filter({ $0.hasEnded() == false && $0.hasStarted() == false }) + + var rounds = [Round]() + + if let groupStageLoserBracketRound = tournament.groupStageLoserBracket() { + rounds.append(groupStageLoserBracketRound) + } + + if shouldEndRoundBeforeStartingNext { + rounds.append(contentsOf: upperRounds.flatMap { + [$0] + $0.loserRoundsAndChildren() + }) + } else { + rounds.append(contentsOf: upperRounds.map { + $0 + } + upperRounds.flatMap { + $0.loserRoundsAndChildren() + }) + } + + let flattenedMatches = rounds.flatMap { round in + round._matches().filter({ $0.disabled == false && $0.hasEnded() == false && $0.hasStarted() == false }).sorted(by: \.index) + } + + flattenedMatches.forEach({ + if (roundId == nil && matchId == nil) || $0.startDate?.isEarlierThan(startDate) == false { + $0.startDate = nil + $0.removeCourt() + $0.confirmed = false + } + }) + +// if let roundId { +// if let round : Round = Store.main.findById(roundId) { +// let matches = round._matches().filter({ $0.disabled == false }).sorted(by: \.index) +// round.resetFromRoundAllMatchesStartDate() +// flattenedMatches = matches + flattenedMatches +// } +// +// } else if let matchId { +// if let match : Match = Store.main.findById(matchId) { +// if let round = match.roundObject { +// round.resetFromRoundAllMatchesStartDate(from: match) +// } +// flattenedMatches = [match] + flattenedMatches +// } +// } + + if let roundId, let matchId { + //todo + if let index = flattenedMatches.firstIndex(where: { $0.round == roundId && $0.id == matchId }) { + flattenedMatches[index...].forEach { + $0.startDate = nil + $0.removeCourt() + $0.confirmed = false + } + } + } else if let roundId { + //todo + if let index = flattenedMatches.firstIndex(where: { $0.round == roundId }) { + flattenedMatches[index...].forEach { + $0.startDate = nil + $0.removeCourt() + $0.confirmed = false + } + } + } + + + let matches: [Match] = allMatches.filter { $0.startDate?.isEarlierThan(startDate) == true && $0.startDate?.dayInt == startDate.dayInt } + let usedCourts = getAvailableCourts(from: matches) + let initialCourts: [Int] = usedCourts.filter { (court, availableDate) in + availableDate <= startDate + }.sorted(by: \.1).compactMap { $0.0 } + + let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts + + print("initial available courts at beginning: \(courts ?? [])") + + let roundDispatch = self.roundDispatcher(flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts) + + roundDispatch.timedMatches.forEach { matchSchedule in + if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) { + match.startDate = matchSchedule.startDate + match.setCourt(matchSchedule.courtIndex) + } + } + + do { + try self.tournamentStore?.matches.addOrUpdate(contentOfs: allMatches) + } catch { + Logger.error(error) + } + + return roundDispatch.issueFound + } + + + public 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) + } + } + + public 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.. (earliestFreeDate: Date, availableCourts: [Int]) { + var earliestEndDate: Date? + var availableCourtsAtEarliest: [Int] = [] + + // Iterate through each court and find the earliest time it becomes free + for courtIndex in courts { + let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex } + var isAvailable = true + + for interval in unavailabilityForCourt { + if interval.startDate <= startDate && interval.endDate > startDate { + isAvailable = false + if let currentEarliest = earliestEndDate { + earliestEndDate = min(currentEarliest, interval.endDate) + } else { + earliestEndDate = interval.endDate + } + } + } + + // If the court is available at the start date, add it to the list of available courts + if isAvailable { + availableCourtsAtEarliest.append(courtIndex) + } + } + + // If there are no unavailable courts, return the original start date and all courts + if let earliestEndDate = earliestEndDate { + // Find which courts will be available at the earliest free date + let courtsAvailableAtEarliest = courts.filter { courtIndex in + let unavailabilityForCourt = courtsUnavailability.filter { $0.courtIndex == courtIndex } + return unavailabilityForCourt.allSatisfy { $0.endDate <= earliestEndDate } + } + return (earliestFreeDate: earliestEndDate, availableCourts: courtsAvailableAtEarliest) + } else { + // If no courts were unavailable, all courts are available at the start date + return (earliestFreeDate: startDate.addingTimeInterval(Double(duration) * 60), availableCourts: courts) + } + } + + public func updateSchedule(tournament: Tournament) -> Bool { + if tournament.courtCount < courtsAvailable.count { + courtsAvailable = Set(tournament.courtsAvailable()) + } + var lastDate = tournament.startDate + if tournament.groupStageCount > 0 { + lastDate = updateGroupStageSchedule(tournament: tournament) + } + if tournament.groupStages(atStep: 1).isEmpty == false { + lastDate = updateGroupStageSchedule(tournament: tournament, atStep: 1, startDate: lastDate) + } + return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) + } +} + +struct GroupStageTimeMatch { + let matchID: String + let rotationIndex: Int + var courtIndex: Int + let groupIndex: Int +} + +struct TimeMatch { + let matchID: String + let rotationIndex: Int + var courtIndex: Int + var startDate: Date + var durationLeft: Int //in minutes + var minimumBreakTime: Int //in minutes + + func estimatedEndDate(includeBreakTime: Bool) -> Date { + let minutesToAdd = Double(durationLeft + (includeBreakTime ? minimumBreakTime : 0)) + return startDate.addingTimeInterval(minutesToAdd * 60.0) + } + + var computedEndDateForSorting: Date { + estimatedEndDate(includeBreakTime: false) + } +} + +struct GroupStageMatchDispatcher { + let timedMatches: [GroupStageTimeMatch] + let freeCourtPerRotation: [Int: [Int]] + let rotationCount: Int + let groupLastRotation: [Int: Int] +} + +struct MatchDispatcher { + let timedMatches: [TimeMatch] + let freeCourtPerRotation: [Int: [Int]] + let rotationCount: Int + let issueFound: Bool +} + +extension Match { + func teamIds() -> [String] { + return teams().map { $0.id } + } + + public func containsTeamId(_ id: String) -> Bool { + return teamIds().contains(id) + } + + public func containsTeamIndex(_ id: String) -> Bool { + matchUp().contains(id) + } + + func matchUp() -> [String] { + guard let groupStageObject else { + return [] + } + + return groupStageObject._matchUp(for: index).map { groupStageObject.id + "_\($0)" } + } +} diff --git a/PadelClubData/Data/MockData.swift b/PadelClubData/Data/MockData.swift new file mode 100644 index 0000000..0bac6dd --- /dev/null +++ b/PadelClubData/Data/MockData.swift @@ -0,0 +1,66 @@ +// +// MockData.swift +// PadelClub +// +// Created by Razmig Sarkissian on 20/03/2024. +// + +import Foundation + +public extension Court { + static func mock() -> Court { + Court(index: 0, club: "", name: "Test") + } +} + +public extension Event { + static func mock() -> Event { + Event() + } +} + +public extension Club { + static func mock() -> Club { + Club(name: "AUC", acronym: "AUC") + } + + static func newEmptyInstance() -> Club { + Club(name: "", acronym: "") + } +} + +public extension GroupStage { + static func mock() -> GroupStage { + GroupStage(tournament: "", index: 0, size: 4) + } +} + +public extension Round { + static func mock() -> Round { + Round(tournament: "", index: 0) + } +} + +public extension Tournament { + static func mock() -> Tournament { + return Tournament(groupStageSortMode: .snake, teamSorting: .inscriptionDate, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .senior) + } +} + +public extension Match { + static func mock() -> Match { + return Match(index: 0) + } +} + +public extension TeamRegistration { + static func mock() -> TeamRegistration { + return TeamRegistration(tournament: "") + } +} + +public extension PlayerRegistration { + static func mock() -> PlayerRegistration { + return PlayerRegistration(firstName: "Raz", lastName: "Shark", sex: .male) + } +} diff --git a/PadelClubData/Data/MonthData.swift b/PadelClubData/Data/MonthData.swift new file mode 100644 index 0000000..55cca06 --- /dev/null +++ b/PadelClubData/Data/MonthData.swift @@ -0,0 +1,33 @@ +// +// MonthData.swift +// PadelClub +// +// Created by Razmig Sarkissian on 18/04/2024. +// + +import Foundation +import SwiftUI +import LeStorage + +@Observable +public final class MonthData: BaseMonthData { + + public init(monthKey: String) { + super.init() + self.monthKey = monthKey + self.creationDate = Date() + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public func total() -> Int { + return (maleCount ?? 0) + (femaleCount ?? 0) + } + +} diff --git a/PadelClubData/Data/PlayerPaymentType.swift b/PadelClubData/Data/PlayerPaymentType.swift new file mode 100644 index 0000000..298213f --- /dev/null +++ b/PadelClubData/Data/PlayerPaymentType.swift @@ -0,0 +1,53 @@ +// +// PlayerPaymentType.swift +// PadelClub +// +// Created by Laurent Morvillier on 11/02/2025. +// + +import Foundation + +public enum PlayerPaymentType: Int, CaseIterable, Identifiable, Codable { + + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public 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 + + public 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" + } + } +} diff --git a/PadelClubData/Data/PlayerRegistration.swift b/PadelClubData/Data/PlayerRegistration.swift new file mode 100644 index 0000000..23ef454 --- /dev/null +++ b/PadelClubData/Data/PlayerRegistration.swift @@ -0,0 +1,253 @@ +// +// PlayerRegistration.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage + +@Observable +public final class PlayerRegistration: BasePlayerRegistration, SideStorable { + + public func localizedSourceLabel() -> String { + switch source { + case .frenchFederation: + return "base fédérale" + case .beachPadel: + return "beach-padel" + case nil: + if registeredOnline { + return "à vérifier vous-même" + } else { + return "créé par vous-même" + } + } + } + + public 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: PlayerRegistration.PlayerDataSource? = nil, hasArrived: Bool = false) { + super.init() + 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 + } + + required init(from decoder: any Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public var tournamentStore: TournamentStore? { + guard let storeId else { + fatalError("missing store id for \(String(describing: type(of: self)))") + } + return TournamentLibrary.shared.store(tournamentId: storeId) +// if let store = self.store as? TournamentStore { +// return store +// } + } + + public var computedAge: Int? { + if let birthdate { + let components = birthdate.components(separatedBy: "/") + if let age = components.last, let ageInt = Int(age) { + let year = Calendar.current.getSportAge() + + if age.count == 2 { //si l'année est sur 2 chiffres dans le fichier + if ageInt < 23 { + return year - 2000 - ageInt + } else { + return year - 2000 + 100 - ageInt + } + } else { //si l'année est représenté sur 4 chiffres + return year - ageInt + } + } + } + return nil + } + + public 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()) + } + } + + public func isPlaying() -> Bool { + team()?.isPlaying() == true + } + + public func contains(_ searchField: String) -> Bool { + let nameComponents = searchField.canonicalVersion.split(separator: " ") + + if nameComponents.count > 1 { + let pairs = nameComponents.pairs() + return pairs.contains(where: { + (firstName.canonicalVersion.localizedCaseInsensitiveContains(String($0)) && + lastName.canonicalVersion.localizedCaseInsensitiveContains(String($1))) || + (firstName.canonicalVersion.localizedCaseInsensitiveContains(String($1)) && + lastName.canonicalVersion.localizedCaseInsensitiveContains(String($0))) + }) + } else { + return nameComponents.contains { component in + firstName.canonicalVersion.localizedCaseInsensitiveContains(component) || + lastName.canonicalVersion.localizedCaseInsensitiveContains(component) + } + } + } + + public func isSameAs(_ player: PlayerRegistration) -> Bool { + firstName.trimmedMultiline.canonicalVersion.localizedCaseInsensitiveCompare(player.firstName.trimmedMultiline.canonicalVersion) == .orderedSame && + lastName.trimmedMultiline.canonicalVersion.localizedCaseInsensitiveCompare(player.lastName.trimmedMultiline.canonicalVersion) == .orderedSame + } + + public func tournament() -> Tournament? { + guard let tournament = team()?.tournament else { return nil } + return Store.main.findById(tournament) + } + + public func team() -> TeamRegistration? { + guard let teamRegistration else { return nil } + return self.tournamentStore?.teamRegistrations.findById(teamRegistration) + } + + public func isHere() -> Bool { + hasArrived + } + + public func hasPaid() -> Bool { + paymentType != nil + } + + public 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 + "." + } + } + + public func isImported() -> Bool { + source == .beachPadel + } + + public func unrankedOrUnknown() -> Bool { + source == nil + } + + public 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 + public var canonicalName: String { + playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased() + } + + public 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") + } + } + + public func setComputedRank(in tournament: Tournament) { + let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 90_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 + } + } + + public func isMalePlayer() -> Bool { + sex == .male + } + + public func validateLicenceId(_ year: Int) { + if let currentLicenceId = licenceId { + if currentLicenceId.trimmed.hasSuffix("(\(year-1))") { + self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)") + } else if let computedLicense = currentLicenceId.strippedLicense?.computedLicense { + self.licenceId = computedLicense + " (\(year))" + } + } + } + + public enum PlayerDataSource: Int, Codable { + case frenchFederation = 0 + case beachPadel = 1 + } + + public static func addon(for playerRank: Int, manMax: Int, womanMax: Int) -> Int { + switch playerRank { + case 0: return 0 + case womanMax: return manMax - womanMax + case manMax: return 0 + default: + return TournamentCategory.femaleInMaleAssimilationAddition(playerRank) + } + } + + func insertOnServer() { + self.tournamentStore?.playerRegistrations.writeChangeAndInsertOnServer(instance: self) + } + +} + +enum PlayerDataSource: Int, Codable { + case frenchFederation = 0 + case beachPadel = 1 +} + +public enum PlayerSexType: Int, Hashable, CaseIterable, Identifiable, Codable { + init?(rawValue: Int?) { + guard let value = rawValue else { return nil } + self.init(rawValue: value) + } + + public var id: Self { + self + } + + case female = 0 + case male = 1 +} diff --git a/PadelClubData/Data/Purchase.swift b/PadelClubData/Data/Purchase.swift new file mode 100644 index 0000000..89c069d --- /dev/null +++ b/PadelClubData/Data/Purchase.swift @@ -0,0 +1,95 @@ +// +// Purchase.swift +// LeStorage +// +// Created by Laurent Morvillier on 12/04/2024. +// + +import Foundation +import LeStorage + +public class Purchase: BasePurchase { + +// static func resourceName() -> String { return "purchases" } +// static func tokenExemptedMethods() -> [HTTPMethod] { return [] } +// static func filterByStoreIdentifier() -> Bool { return false } +// static var relationshipNames: [String] = [] +// +// var id: UInt64 +// var lastUpdate: Date +// var user: String +// var purchaseDate: Date +// var productId: String +// var quantity: Int? +// var revocationDate: Date? = nil +// var expirationDate: Date? = nil + + init(transactionId: UInt64, user: String, purchaseDate: Date, productId: String, quantity: Int? = nil, revocationDate: Date? = nil, expirationDate: Date? = nil) { + super.init(id: transactionId, user: user, purchaseDate: purchaseDate, productId: productId, quantity: quantity, revocationDate: revocationDate, expirationDate: expirationDate) + +// self.id = transactionId +// self.lastUpdate = Date() +// self.user = user +// self.purchaseDate = purchaseDate +// self.productId = productId +// self.quantity = quantity +// self.revocationDate = revocationDate +// self.expirationDate = expirationDate + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + +// enum CodingKeys: String, CodingKey, CaseIterable { +// case id +// case lastUpdate +// case user +// case purchaseDate +// case productId +// case quantity +// case revocationDate +// case expirationDate +// } + + func isValid() -> Bool { + guard self.revocationDate == nil else { + return false + } + guard let expiration = self.expirationDate else { + return false + } + return expiration > Date() + } + +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// +// try container.encode(self.id, forKey: .id) +// try container.encode(self.lastUpdate, forKey: .lastUpdate) +// try container.encodeAndEncryptIfPresent(self.user.data(using: .utf8), forKey: .user) +// try container.encode(self.purchaseDate, forKey: .purchaseDate) +// try container.encode(self.productId, forKey: .productId) +// try container.encode(self.quantity, forKey: .quantity) +// try container.encode(self.revocationDate, forKey: .revocationDate) +// try container.encode(self.expirationDate, forKey: .expirationDate) +// } +// +// required init(from decoder: any Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// +// self.id = try container.decode(UInt64.self, forKey: .id) +// self.lastUpdate = try container.decodeIfPresent(Date.self, forKey: .lastUpdate) ?? Date() +// self.user = try container.decodeEncrypted(key: .user) +// self.purchaseDate = try container.decode(Date.self, forKey: .purchaseDate) +// self.productId = try container.decode(String.self, forKey: .productId) +// self.quantity = try container.decodeIfPresent(Int.self, forKey: .quantity) +// self.revocationDate = try container.decodeIfPresent(Date.self, forKey: .revocationDate) +// self.expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate) +// } + +} diff --git a/PadelClubData/Data/README.md b/PadelClubData/Data/README.md new file mode 100644 index 0000000..d72ad3a --- /dev/null +++ b/PadelClubData/Data/README.md @@ -0,0 +1,35 @@ +# Procédure de création de classe +Dans Swift: +- Dans Data > Gen > créer un nouveau fichier json pour la classe + - Le paramètre "foreignKey" permet de générer une méthode qui récupère l'objet parent. Ajouter une étoile permet d'indiquer que l'on cherche l'objet dans le Store de l'objet et non le Store.main. +- Pour générer les fichiers, on se place dans le répertoire de generator.py et on lance la commande : python generator.py -i . -o . +- il faut avoir inflect: pip install inflect + +Dans Django: +- Les modèles de base doivent étendre BaseModel +- Les modèles stockés dans des répertoires doivent étendre SideStoreModel +- Les classes d'admin doivent étendre SyncedObjectAdmin +- Les ForeignKey doivent toujours avoir on_delete=models.SET_NULL + - Pour se faciliter la vie dans l'admin, on veut que les delete effacent les enfants malgré tout. Il faut donc implémenter la méthode delete_dependencies(self) de la classe + + +# Procédure d'ajout de champ dans une classe + +Dans Swift: +- Ouvrir le fichier .json correspondant à la classe +- Regénérer la classe, voir ci-dessus pour la commande +- Ouvrir **ServerDataTests** et ajouter un test sur le champ + - Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple + +Dans Django: +- Ajouter le champ dans la classe +- Si c'est une ForeignKey, *toujours* mettre un related_name sinon la synchro casse +- Si c'est un champ dans **CustomUser**: + - Ajouter le champ à la méthode fields_for_update + - Ajouter le champ dans UserSerializer > create > create_user dans serializers.py + - L'ajouter aussi dans admin.py si nécéssaire +- Faire le *makemigrations* + *migrate* + +Note: Les nouvelles classes de model doivent étendre BaseModel ou SideStoreModel + +Enfin, revenir dans Xcode, ouvrir ServerDataTests et lancer le test mis à jour diff --git a/PadelClubData/Data/Round.swift b/PadelClubData/Data/Round.swift new file mode 100644 index 0000000..fb24760 --- /dev/null +++ b/PadelClubData/Data/Round.swift @@ -0,0 +1,850 @@ +// +// Round.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class Round: BaseRound, SideStorable { + + var _cachedSeedInterval: SeedInterval? + + public init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil, groupStageLoserBracket: Bool = false, loserBracketMode: LoserBracketMode = .automatic) { + + super.init(tournament: tournament, index: index, parent: parent, format: matchFormat, startDate: startDate, groupStageLoserBracket: groupStageLoserBracket, loserBracketMode: loserBracketMode) + +// self.lastUpdate = Date() +// self.tournament = tournament +// self.index = index +// self.parent = parent +// self.format = matchFormat +// self.startDate = startDate +// self.groupStageLoserBracket = groupStageLoserBracket +// self.loserBracketMode = loserBracketMode + } + + required init(from decoder: any Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + // MARK: - Computed dependencies + + public var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.tournament) + } + + public func tournamentObject() -> Tournament? { + return Store.main.findById(tournament) + } + + public func _matches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter { $0.round == self.id }.sorted(by: \.index) + } + + public func getDisabledMatches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true } + } + + // MARK: - + + public var matchFormat: MatchFormat { + get { + format ?? .defaultFormatForMatchType(.bracket) + } + set { + format = newValue + } + } + + public func hasStarted() -> Bool { + return playedMatches().anySatisfy({ $0.hasStarted() }) + } + + public func hasEnded() -> Bool { + if isUpperBracket() { + return playedMatches().anySatisfy({ $0.hasEnded() == false }) == false + } else { + return enabledMatches().anySatisfy({ $0.hasEnded() == false }) == false + } + } + + public 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 [] + } + + public func previousMatches(ofMatch match: Match) -> [Match] { + guard let previousRound = previousRound() else { return [] } + guard let tournamentStore = self.tournamentStore else { return [] } + + return 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 +// } + } + + public 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 + } + } + + public func team(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? { + return roundProjectedTeam(team, inMatch: match, previousRound: previousRound) + } + + public func seed(_ team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? { + return self.tournamentStore?.teamRegistrations.first(where: { + $0.bracketPosition != nil + && ($0.bracketPosition! / 2) == matchIndex + && ($0.bracketPosition! % 2) == team.rawValue + }) + } + + public func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return 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 +// }) + } + + public func seeds() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + let initialMatchIndex = RoundRule.matchIndex(fromRoundIndex: index) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index) + return tournamentStore.teamRegistrations.filter { + $0.bracketPosition != nil + && ($0.bracketPosition! / 2) >= initialMatchIndex + && ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches + } + } + + public func teamsOrSeeds() -> [TeamRegistration] { + let seeds = seeds() + if seeds.isEmpty { + return playedMatches().flatMap({ $0.teams() }) + } else { + return seeds + } + } + + + public func losers() -> [TeamRegistration] { + let teamIds: [String] = self._matches().compactMap { $0.losingTeamId } + return teamIds.compactMap { self.tournamentStore?.teamRegistrations.findById($0) } + } + + public func winners() -> [TeamRegistration] { + let teamIds: [String] = self._matches().compactMap { $0.winningTeamId } + return teamIds.compactMap { self.tournamentStore?.teamRegistrations.findById($0) } + } + + public func teams() -> [TeamRegistration] { + return playedMatches().flatMap({ $0.teams() }) + } + + public 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 self.store?.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 self.store?.findById(parent) + } + } + + return nil + } + + public 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 parentRound = parentRound + if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic { + return nil + } + + let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) + if isLoserBracket(), previousRound == nil, let upperBracketTopMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2) { + return upperBracketTopMatch + } + return nil + } + + public 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 parentRound = parentRound + if let parentRound, parentRound.parent == nil, groupStageLoserBracket == false, parentRound.loserBracketMode != .automatic { + return nil + } + + let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) + if isLoserBracket(), previousRound == nil, let upperBracketBottomMatch = parentRound?.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) { + return upperBracketBottomMatch + } + return nil + } + + + public 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 + }) + } + + public 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 + }) + } + + public func getMatch(atMatchIndexInRound matchIndexInRound: Int) -> Match? { + self.tournamentStore?.matches.first(where: { + let index = RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index) + return $0.round == id && index == matchIndexInRound + }) + } + + public func enabledMatches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return 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() +// } +// } + + public func playedMatches() -> [Match] { + if isUpperBracket() { + return enabledMatches() + } else { + return _matches() + } + } + + public 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 }) + } + + public func nextRound() -> Round? { + return self.tournamentStore?.rounds.first(where: { $0.parent == parent && $0.index == index - 1 }) + } + + public func loserRounds(forRoundIndex roundIndex: Int) -> [Round] { + return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount) + } + + public func loserRounds(forRoundIndex roundIndex: Int, loserRoundsAndChildren: [Round]) -> [Round] { + return loserRoundsAndChildren.filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount) + } + + public func isDisabled() -> Bool { + return _matches().allSatisfy({ $0.disabled }) + } + + public 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() + } + + public 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 + } + + public func enableRound() { + _toggleRound(disable: false) + } + + public 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) +// } + } + self.tournamentStore?.matches.addOrUpdate(contentOfs: _matches) + } + + public var cumulativeMatchCount: Int { + var totalMatches = playedMatches().count + if let parentRound { + totalMatches += parentRound.cumulativeMatchCount + } + return totalMatches + } + + public func initialRound() -> Round? { + if let parentRound { + return parentRound.initialRound() + } else { + return self + } + } + + public func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? { + return enabledMatches().last?.estimatedEndDate(additionalEstimationDuration) + } + + public func getLoserRoundStartDate() -> Date? { + return loserRoundsAndChildren().first(where: { $0.isDisabled() == false })?.enabledMatches().first?.startDate + } + + public func estimatedLoserRoundEndDate(_ additionalEstimationDuration: Int) -> Date? { + let lastMatch = loserRoundsAndChildren().last(where: { $0.isDisabled() == false })?.enabledMatches().last + return lastMatch?.estimatedEndDate(additionalEstimationDuration) + } + + public func disabledMatches() -> [Match] { + return _matches().filter({ $0.disabled }) + } + + public func allLoserRoundMatches() -> [Match] { + loserRoundsAndChildren().flatMap({ $0._matches() }) + } + + public var theoryCumulativeMatchCount: Int { + var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index) + if let parentRound { + totalMatches += parentRound.theoryCumulativeMatchCount + } + return totalMatches + } + + + public func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String { + if let _cachedSeedInterval { return _cachedSeedInterval.localizedLabel(displayStyle) } + +#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) + var seedsAfterThisRound: [TeamRegistration] = [] + if let tournamentStore = tournamentStore { + seedsAfterThisRound = tournamentStore.teamRegistrations.filter { + $0.bracketPosition != nil + && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex + } + } + +// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: { +// $0.tournament == tournament +// && $0.bracketPosition != nil +// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex +// }) + + var seedsCount = seedsAfterThisRound.count + if seedsAfterThisRound.isEmpty { + let nextRoundsDisableMatches = nextRoundsDisableMatches() + seedsCount = disabledMatches().count - nextRoundsDisableMatches + } + let playedMatches = playedMatches() + let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount) + + _cachedSeedInterval = seedInterval + return seedInterval.localizedLabel(displayStyle) + } + + func hasNextRound() -> Bool { + return nextRound()?.isRankDisabled() == false + } + + public 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") + } + + public func seedInterval(initialMode: Bool = false) -> SeedInterval? { + if initialMode == false, let _cachedSeedInterval { return _cachedSeedInterval } + +#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 isUpperBracket() { + if index == 0 { return SeedInterval(first: 1, last: 2) } + let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index) + + if initialMode { + let playedMatches = RoundRule.numberOfMatches(forRoundIndex: index) + let seedInterval = SeedInterval(first: playedMatches + 1, last: playedMatches * 2) + //print(seedInterval.localizedLabel()) + return seedInterval + } else { + var seedsAfterThisRound : [TeamRegistration] = [] + if let tournamentStore = self.tournamentStore { + seedsAfterThisRound = tournamentStore.teamRegistrations.filter { + $0.bracketPosition != nil + && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex + } + } + + var seedsCount = seedsAfterThisRound.count + if seedsAfterThisRound.isEmpty { + let nextRoundsDisableMatches = nextRoundsDisableMatches() + seedsCount = disabledMatches().count - nextRoundsDisableMatches + } + + let playedMatches = playedMatches() + //print("playedMatches \(playedMatches)", initialMode, parent, parentRound?.roundTitle(), seedsAfterThisRound.count) + let seedInterval = SeedInterval(first: playedMatches.count + seedsCount + 1, last: playedMatches.count * 2 + seedsCount) + //print(seedInterval.localizedLabel()) + _cachedSeedInterval = seedInterval + return seedInterval + + } + } + + if let previousRound = previousRound() { + if (previousRound.enabledMatches().isEmpty == false || initialMode) { + return previousRound.seedInterval(initialMode: initialMode)?.chunks()?.first + } else { + return previousRound.seedInterval(initialMode: initialMode) + } + } else if let parentRound { + if parentRound.isUpperBracket() { + return parentRound.seedInterval(initialMode: initialMode) + } + return parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last + } + + return nil + } + + public func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String { + if groupStageLoserBracket { + return "Classement Poules" + } + + if parent != nil { + if let seedInterval = seedInterval(initialMode: initialMode) { + return seedInterval.localizedLabel(displayStyle) + } +// print("Round pas trouvé", id, parent, index) + return "Match de classement" + } + return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle) + } + + public func updateTournamentState() { + let tournamentObject = tournamentObject() + if let tournamentObject, index == 0, isUpperBracket(), hasEnded() { + tournamentObject.endDate = Date() + DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject) + } + + tournamentObject?.updateTournamentState() + } + + public func roundStatus() -> String { + let hasEnded = hasEnded() + if hasStarted() && hasEnded == false { + return "en cours" + } else if hasEnded { + return "terminée" + } else { + return "à démarrer" + } + } + + public func loserRounds() -> [Round] { + guard let tournamentStore = self.tournamentStore else { return [] } +#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 tournamentStore.rounds.filter( { $0.parent == id }).sorted(by: \.index).reversed() + } + + public func loserRoundsAndChildren() -> [Round] { + let loserRounds = loserRounds() + return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() }) + } + + public func isUpperBracket() -> Bool { + return parent == nil && groupStageLoserBracket == false + } + + public func isLoserBracket() -> Bool { + return parent != nil || groupStageLoserBracket + } + + public func deleteLoserBracket() { + let loserRounds = loserRounds() + self.tournamentStore?.rounds.delete(contentOfs: loserRounds) + } + + public 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.. Int { + if parent == nil, index > 0 { + return tournamentObject()?.rounds().suffix(index).flatMap { $0.disabledMatches() }.count ?? 0 + } else { + return 0 + } + } + + public 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) + } + } + } + } + + public func updateMatchFormatAndAllMatches(_ updatedMatchFormat: MatchFormat) { + self.matchFormat = updatedMatchFormat + self.updateMatchFormatOfAllMatches(updatedMatchFormat) + } + + public func updateMatchFormatOfAllMatches(_ updatedMatchFormat: MatchFormat) { + let playedMatches = _matches() + playedMatches.forEach { match in + match.matchFormat = updatedMatchFormat + } + self.tournamentStore?.matches.addOrUpdate(contentOfs: playedMatches) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let matches = self._matches() + for match in matches { + match.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + + self.tournamentStore?.matches.deleteDependencies(matches, shouldBeSynchronized: shouldBeSynchronized) + + let loserRounds = self.loserRounds() + for round in loserRounds { + round.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + + self.tournamentStore?.rounds.deleteDependencies(loserRounds, shouldBeSynchronized: shouldBeSynchronized) + } + +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _storeId = "storeId" +// case _lastUpdate = "lastUpdate" +// case _tournament = "tournament" +// case _index = "index" +// case _parent = "parent" +// case _format = "format" +// case _startDate = "startDate" +// case _groupStageLoserBracket = "groupStageLoserBracket" +// case _loserBracketMode = "loserBracketMode" +// } +// +// required init(from decoder: Decoder) throws { +// let container = try decoder.container(keyedBy: CodingKeys.self) +// id = try container.decode(String.self, forKey: ._id) +// storeId = try container.decode(String.self, forKey: ._storeId) +// lastUpdate = try container.decodeIfPresent(Date.self, forKey: ._lastUpdate) ?? Date() +// tournament = try container.decode(String.self, forKey: ._tournament) +// index = try container.decode(Int.self, forKey: ._index) +// parent = try container.decodeIfPresent(String.self, forKey: ._parent) +// format = try container.decodeIfPresent(MatchFormat.self, forKey: ._format) +// startDate = try container.decodeIfPresent(Date.self, forKey: ._startDate) +// groupStageLoserBracket = try container.decodeIfPresent(Bool.self, forKey: ._groupStageLoserBracket) ?? false +// loserBracketMode = try container.decodeIfPresent(LoserBracketMode.self, forKey: ._loserBracketMode) ?? .automatic +// } +// +// func encode(to encoder: Encoder) throws { +// var container = encoder.container(keyedBy: CodingKeys.self) +// +// try container.encode(id, forKey: ._id) +// try container.encode(lastUpdate, forKey: ._lastUpdate) +// try container.encode(storeId, forKey: ._storeId) +// try container.encode(tournament, forKey: ._tournament) +// try container.encode(index, forKey: ._index) +// try container.encode(groupStageLoserBracket, forKey: ._groupStageLoserBracket) +// try container.encode(loserBracketMode, forKey: ._loserBracketMode) +// +// try container.encode(parent, forKey: ._parent) +// try container.encode(format, forKey: ._format) +// try container.encode(startDate, forKey: ._startDate) +// +// } + + func insertOnServer() { + self.tournamentStore?.rounds.writeChangeAndInsertOnServer(instance: self) + for match in self._matches() { + match.insertOnServer() + } + } + +} + +extension Round: Selectable { + + public func selectionLabel(index: Int) -> String { + if let parentRound { + return "Tour #\(parentRound.loserRounds().count - index)" + } else { + return roundTitle(.short) + } + } + + public 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 + } + } + + public func badgeValueColor() -> Color? { + return nil + } + + public 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 + } +} + + +public enum LoserBracketMode: Int, CaseIterable, Identifiable, Codable { + public var id: Int { self.rawValue } + + case automatic + case manual + + public func localizedLoserBracketMode() -> String { + switch self { + case .automatic: + "Automatique" + case .manual: + "Manuelle" + } + } + + public func localizedLoserBracketModeDescription() -> String { + switch self { + case .automatic: + "Les perdants du tableau principal sont placés à leur place prévue." + case .manual: + "Aucun placement automatique n'est fait. Vous devez choisir les perdants qui se jouent." + } + } +} diff --git a/PadelClubData/Data/TeamRegistration.swift b/PadelClubData/Data/TeamRegistration.swift new file mode 100644 index 0000000..7632ef0 --- /dev/null +++ b/PadelClubData/Data/TeamRegistration.swift @@ -0,0 +1,712 @@ +// +// TeamRegistration.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class TeamRegistration: BaseTeamRegistration, SideStorable { + + // 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 lastUpdate: Date + // var tournament: String + // var groupStage: String? + // var registrationDate: Date? + // var callDate: Date? + // var bracketPosition: Int? + // var groupStagePosition: Int? + // var comment: String? + // var source: String? + // var sourceValue: String? + // var logo: String? + // var name: String? + // + // var walkOut: Bool = false + // var wildCardBracket: Bool = false + // var wildCardGroupStage: Bool = false + // var weight: Int = 0 + // var lockedWeight: Int? + // var confirmationDate: Date? + // var qualified: Bool = false + // var finalRanking: Int? + // var pointsEarned: Int? + // + // var storeId: String? = nil + + public 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 + ) { + + super.init() + + // self.storeId = tournament + self.tournament = tournament + self.groupStage = groupStage + self.registrationDate = registrationDate ?? Date() + self.callDate = callDate + self.bracketPosition = bracketPosition + self.groupStagePosition = groupStagePosition + self.comment = comment + self.source = source + self.sourceValue = sourceValue + self.logo = logo + self.name = name + self.walkOut = walkOut + self.wildCardBracket = wildCardBracket + self.wildCardGroupStage = wildCardGroupStage + self.weight = weight + self.lockedWeight = lockedWeight + self.confirmationDate = confirmationDate + self.qualified = qualified + } + + public func hasRegisteredOnline() -> Bool { + players().anySatisfy({ $0.registeredOnline }) + } + + public func unrankedOrUnknown() -> Bool { + players().anySatisfy({ $0.source == nil }) + } + + public func isOutOfTournament() -> Bool { + walkOut + } + + required init(from decoder: any Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.tournament) + } + + // MARK: - Computed dependencies + + public func unsortedPlayers() -> [PlayerRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.playerRegistrations.filter { + $0.teamRegistration == self.id && $0.coach == false + } + } + + // MARK: - + + public func deleteTeamScores() { + guard let tournamentStore = self.tournamentStore else { return } + let ts = tournamentStore.teamScores.filter({ $0.teamRegistration == id }) + tournamentStore.teamScores.delete(contentOfs: ts) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + let unsortedPlayers = unsortedPlayers() + for player in unsortedPlayers { + player.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + self.tournamentStore?.playerRegistrations.deleteDependencies(unsortedPlayers, shouldBeSynchronized: shouldBeSynchronized) + + let teamScores = teamScores() + for teamScore in teamScores { + teamScore.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + self.tournamentStore?.teamScores.deleteDependencies(teamScores, shouldBeSynchronized: shouldBeSynchronized) + } + + public func hasArrived(isHere: Bool = false) { + let unsortedPlayers = unsortedPlayers() + unsortedPlayers.forEach({ $0.hasArrived = !isHere }) + self.tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: unsortedPlayers) + } + + public func isHere() -> Bool { + let unsortedPlayers = unsortedPlayers() + if unsortedPlayers.isEmpty { return false } + return unsortedPlayers.allSatisfy({ $0.hasArrived }) + } + + public func isSeedable() -> Bool { + bracketPosition == nil && groupStage == nil + } + + public func setSeedPosition(inSpot match: Match, slot: TeamPosition?, opposingSeeding: Bool) { + var teamPosition: TeamPosition { + if let slot { + return slot + } else { + let matchIndex = match.index + let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex) + let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound) + let isUpper = + RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) + < (numberOfMatches / 2) + var teamPosition = slot ?? (isUpper ? .one : .two) + if opposingSeeding { + teamPosition = slot ?? (isUpper ? .two : .one) + } + return teamPosition + } + } + + let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: teamPosition) + tournamentObject()?.resetTeamScores(in: bracketPosition) + self.bracketPosition = seedPosition + if groupStagePosition != nil && qualified == false { + qualified = true + } + if let tournament = tournamentObject() { + if let index = index(in: tournament.selectedSortedTeams()) { + let drawLog = DrawLog( + tournament: tournament.id, drawSeed: index, drawMatchIndex: match.index, + drawTeamPosition: teamPosition, drawType: .seed) + do { + try tournamentStore?.drawLogs.addOrUpdate(instance: drawLog) + } catch { + Logger.error(error) + } + } + tournament.updateTeamScores(in: bracketPosition) + } + } + + public func expectedSummonDate() -> Date? { + if let groupStageStartDate = groupStageObject()?.startDate { + return groupStageStartDate + } else if let roundMatchStartDate = initialMatch()?.startDate { + return roundMatchStartDate + } + return nil + } + + public var initialWeight: Int { + return lockedWeight ?? weight + } + + public func called() -> Bool { + return callDate != nil + } + + public func confirmed() -> Bool { + return confirmationDate != nil + } + + public func getPhoneNumbers() -> [String] { + return players().compactMap { $0.phoneNumber }.filter({ $0.isEmpty == false }) + } + + public func getMail() -> [String] { + let mails = players().compactMap({ $0.email }) + return mails + } + + public func isImported() -> Bool { + let unsortedPlayers = unsortedPlayers() + if unsortedPlayers.isEmpty { return false } + + return unsortedPlayers.allSatisfy({ $0.isImported() }) + } + + public func isWildCard() -> Bool { + return wildCardBracket || wildCardGroupStage + } + + public func isPlaying() -> Bool { + return currentMatch() != nil + } + + func currentMatch() -> Match? { + return teamScores().compactMap { $0.matchObject() }.first(where: { $0.isRunning() }) + } + + func teamScores() -> [TeamScore] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamScores.filter({ $0.teamRegistration == id }) + } + + func wins() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter({ $0.winningTeamId == id }) + } + + func loses() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter({ $0.losingTeamId == id }) + } + + func matches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return 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 + }) + } + + public func teamLabel( + _ displayStyle: DisplayStyle = .wide, twoLines: Bool = false, separator: String = "&" + ) -> String { + if let name { return name } + return players().map { $0.playerLabel(displayStyle) }.joined( + separator: twoLines ? "\n" : " \(separator) ") + } + + public func teamLabelRanked(displayRank: Bool, displayTeamName: Bool) -> String { + [ + displayTeamName ? name : nil, displayRank ? seedIndex() : nil, + displayTeamName ? (name == nil ? teamLabel() : name) : teamLabel(), + ].compactMap({ $0 }).joined(separator: " ") + } + + public func seedIndex() -> String? { + guard let tournament = tournamentObject() else { return nil } + guard let index = index(in: tournament.selectedSortedTeams()) else { return nil } + return "(\(index + 1))" + } + + public func index(in teams: [TeamRegistration]) -> Int? { + return teams.firstIndex(where: { $0.id == id }) + } + + public func formattedSeed(in teams: [TeamRegistration]? = nil) -> String { + let selectedSortedTeams = teams ?? tournamentObject()?.selectedSortedTeams() ?? [] + if let index = index(in: selectedSortedTeams) { + return "#\(index + 1)" + } else { + return "###" + } + } + + public func contains(_ searchField: String) -> Bool { + return unsortedPlayers().anySatisfy({ $0.contains(searchField) }) + || self.name?.localizedCaseInsensitiveContains(searchField) == true + } + + public func containsExactlyPlayerLicenses(_ playerLicenses: [String?]) -> Bool { + let arrayOfIds: [String] = unsortedPlayers().compactMap({ + $0.licenceId?.strippedLicense?.canonicalVersion + }) + let ids: Set = Set(arrayOfIds.sorted()) + let searchedIds = Set( + playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted()) + if ids.isEmpty || searchedIds.isEmpty { return false } + return ids.hashValue == searchedIds.hashValue + } + + public 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) + } + } + } + + public func includes(player: PlayerRegistration) -> Bool { + return unsortedPlayers().anySatisfy { _player in + _player.isSameAs(player) + } + } + + public func canPlay() -> Bool { + let unsortedPlayers = unsortedPlayers() + if unsortedPlayers.isEmpty { return false } + + return matches().isEmpty == false + || unsortedPlayers.allSatisfy({ $0.hasPaid() || $0.hasArrived }) + } + + public func availableForSeedPick() -> Bool { + return groupStage == nil && bracketPosition == nil + } + + public func inGroupStage() -> Bool { + return groupStagePosition != nil + } + + public func inRound() -> Bool { + return bracketPosition != nil + } + + public func positionLabel() -> String? { + if groupStagePosition != nil { return "Poule" } + if let initialRound = initialRound() { + return initialRound.roundTitle() + } else { + return nil + } + } + + public func resetGroupeStagePosition() { + guard let tournamentStore = self.tournamentStore else { return } + if let groupStage { + let matches = tournamentStore.matches.filter({ $0.groupStage == groupStage }).map { + $0.id + } + let teamScores = tournamentStore.teamScores.filter({ + $0.teamRegistration == id && matches.contains($0.match) + }) + tournamentStore.teamScores.delete(contentOfs: teamScores) + } + //groupStageObject()?._matches().forEach({ $0.updateTeamScores() }) + groupStage = nil + groupStagePosition = nil + } + + public func resetBracketPosition() { + guard let tournamentStore = self.tournamentStore else { return } + let matches = tournamentStore.matches.filter({ $0.groupStage == nil }).map { $0.id } + let teamScores = tournamentStore.teamScores.filter({ + $0.teamRegistration == id && matches.contains($0.match) + }) + tournamentStore.teamScores.delete(contentOfs: teamScores) + + self.bracketPosition = nil + } + + public func resetPositions() { + resetGroupeStagePosition() + resetBracketPosition() + } + + public 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()) + } + } + + public var computedRegistrationDate: Date { + return registrationDate ?? .distantFuture + } + + public func formattedInscriptionDate(_ exportFormat: ExportFormat = .rawText) -> String? { + guard let registrationDate else { return nil } + + let formattedDate = registrationDate.formatted( + .dateTime.weekday().day().month().hour().minute()) + let onlineSuffix = hasRegisteredOnline() ? " en ligne" : "" + + switch exportFormat { + case .rawText: + return "Inscrit\(onlineSuffix) le \(formattedDate)" + case .csv: + return formattedDate + } + } + + public 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 + } + } + } + + public 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()) + } + } + + public typealias TeamRange = (left: TeamRegistration?, right: TeamRegistration?) + + public 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) + } + + public 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 + + public func players() -> [PlayerRegistration] { + + self.unsortedPlayers().sorted { (lhs, rhs) in + let predicates: [AreInIncreasingOrder] = [ + { $0.sex?.rawValue ?? 0 < $1.sex?.rawValue ?? 0 }, + { $0.rank ?? Int.max < $1.rank ?? Int.max }, + { $0.lastName < $1.lastName }, + { $0.firstName < $1.firstName }, + ] + + for predicate in predicates { + if !predicate(lhs, rhs) && !predicate(rhs, lhs) { + continue + } + + return predicate(lhs, rhs) + } + + return false + } + } + + func coaches() -> [PlayerRegistration] { + guard let store = self.tournamentStore else { return [] } + return store.playerRegistrations.filter { $0.coach } + } + + 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) ?? 90_000 + } + + public func groupStageObject() -> GroupStage? { + guard let groupStage else { return nil } + return self.tournamentStore?.groupStages.findById(groupStage) + } + + public 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 }) + } + + public 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 + }) + } + + public func toggleSummonConfirmation() { + if confirmationDate == nil { confirmationDate = Date() } else { confirmationDate = nil } + } + + public func didConfirmSummon() -> Bool { + confirmationDate != nil + } + + public func tournamentObject() -> Tournament? { + return Store.main.findById(tournament) + } + + public func groupStagePositionAtStep(_ step: Int) -> Int? { + guard let groupStagePosition else { return nil } + if step == 0 { + return groupStagePosition + } else if let groupStageObject = groupStageObject(), groupStageObject.hasEnded() { + return groupStageObject.index + } + return nil + } + + public func wildcardLabel() -> String? { + if isWildCard() { + let wildcardLabel: String = ["Wildcard", (wildCardBracket ? "Tableau" : "Poule")].joined(separator: " ") + return wildcardLabel + } else { + return nil + } + } + + var _cachedRestingTime: (Bool, Date?)? + + public func restingTime() -> Date? { + if let _cachedRestingTime { return _cachedRestingTime.1 } + let restingTime = matches().filter({ $0.hasEnded() }).sorted( + by: \.computedEndDateForSorting + ).last?.endDate + _cachedRestingTime = (true, restingTime) + return restingTime + } + + public func resetRestingTime() { + _cachedRestingTime = nil + } + + public var restingTimeForSorting: Date { + restingTime()! + } + + public func teamNameLabel() -> String { + if let name, name.isEmpty == false { + return name + } else { + return "Toute l'équipe" + } + } + + public func isDifferentPosition(_ drawMatchIndex: Int?) -> Bool { + if let bracketPosition, let drawMatchIndex { + return drawMatchIndex != bracketPosition + } else if bracketPosition != nil { + return true + } else if drawMatchIndex != nil { + return true + } + return false + } + + public func shouldDisplayRankAndWeight() -> Bool { + unsortedPlayers().count > 0 + } + + public func teamInitialPositionBracket() -> String? { + let round = initialMatch()?.roundAndMatchTitle() + if let round { + return round + } + return nil + } + + public func teamInitialPositionGroupStage() -> String? { + let groupStage = self.groupStageObject() + let group = groupStage?.groupStageTitle(.title) + var groupPositionLabel: String? = nil + if let finalPosition = groupStage?.finalPosition(ofTeam: self) { + groupPositionLabel = (finalPosition + 1).ordinalFormatted() + } else if let groupStagePosition { + groupPositionLabel = "\(groupStagePosition + 1)" + } + + if let group { + if let groupPositionLabel { + return [group, "#\(groupPositionLabel)"].joined(separator: " ") + } else { + return group + } + } + return nil + } + + public func qualifiedStatus(hideBracketStatus: Bool = false) -> String? { + let teamInitialPositionBracket = teamInitialPositionBracket() + let groupStageTitle = teamInitialPositionGroupStage() + + let base: String? = qualified ? "Qualifié" : nil + if let groupStageTitle, let teamInitialPositionBracket, hideBracketStatus == false { + return [base, groupStageTitle, ">", teamInitialPositionBracket].compactMap({ $0 }).joined(separator: " ") + } else if let groupStageTitle { + return [base, groupStageTitle].compactMap({ $0 }).joined(separator: " ") + } else if hideBracketStatus == false { + return teamInitialPositionBracket + } + + return nil + } + + func insertOnServer() { + self.tournamentStore?.teamRegistrations.writeChangeAndInsertOnServer(instance: self) + for playerRegistration in self.unsortedPlayers() { + playerRegistration.insertOnServer() + } + } + + // MARK: - Refacto + + public func setWeight( + from players: [PlayerRegistration], + inTournamentCategory tournamentCategory: TournamentCategory + ) { + + let significantPlayerCount = significantPlayerCount() + let sortedPlayers = players.sorted(by: \.computedRank, order: .ascending) + weight = (sortedPlayers.prefix(significantPlayerCount).map { $0.computedRank } + missingPlayerType(inTournamentCategory: tournamentCategory).map { unrankValue(for: $0 == 1 ? true : false ) }).prefix(significantPlayerCount).reduce(0,+) + } + + +} + +enum TeamDataSource: Int, Codable { + case beachPadel +} diff --git a/PadelClubData/Data/TeamScore.swift b/PadelClubData/Data/TeamScore.swift new file mode 100644 index 0000000..7c0f7f5 --- /dev/null +++ b/PadelClubData/Data/TeamScore.swift @@ -0,0 +1,117 @@ +// +// TeamScore.swift +// Padel Tournament +// +// Created by razmig on 10/03/2024. +// + +import Foundation +import LeStorage + +@Observable +public final class TeamScore: BaseTeamScore, SideStorable { + + +// 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 lastUpdate: Date +// var match: String +// var teamRegistration: String? +// //var playerRegistrations: [String] = [] +// var score: String? +// var walkOut: Int? +// var luckyLoser: Int? +// +// var storeId: String? = nil + + init(match: String, teamRegistration: String? = nil, score: String? = nil, walkOut: Int? = nil, luckyLoser: Int? = nil) { + super.init(match: match, teamRegistration: teamRegistration, score: score, walkOut: walkOut, luckyLoser: luckyLoser) + +// self.match = match +// self.teamRegistration = teamRegistration +//// self.playerRegistrations = playerRegistrations +// self.score = score +// self.walkOut = walkOut +// self.luckyLoser = luckyLoser + } + + init(match: String, team: TeamRegistration?) { + super.init(match: match) + if let team { + self.teamRegistration = team.id + } + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + var tournamentStore: TournamentStore? { + guard let storeId else { + fatalError("missing store id for \(String(describing: type(of: self)))") + } + return TournamentLibrary.shared.store(tournamentId: storeId) +// +// 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) + } + + public var team: TeamRegistration? { + guard let teamRegistration else { + return nil + } + return self.tournamentStore?.teamRegistrations.findById(teamRegistration) + } + + // MARK: - + + public func isWalkOut() -> Bool { + return walkOut != nil + } + +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _storeId = "storeId" +// case _lastUpdate = "lastUpdate" +// 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(storeId, forKey: ._storeId) +// try container.encode(lastUpdate, forKey: ._lastUpdate) +// try container.encode(match, forKey: ._match) +// try container.encode(teamRegistration, forKey: ._teamRegistration) +// try container.encode(score, forKey: ._score) +// try container.encode(walkOut, forKey: ._walkOut) +// try container.encode(luckyLoser, forKey: ._luckyLoser) +// } + + func insertOnServer() { + self.tournamentStore?.teamScores.writeChangeAndInsertOnServer(instance: self) + } + +} diff --git a/PadelClubData/Data/Tournament.swift b/PadelClubData/Data/Tournament.swift new file mode 100644 index 0000000..fb5499e --- /dev/null +++ b/PadelClubData/Data/Tournament.swift @@ -0,0 +1,2214 @@ +// +// swift +// PadelClub +// +// Created by Laurent Morvillier on 02/02/2024. +// + +import Foundation +import LeStorage +import SwiftUI + +@Observable +public final class Tournament: BaseTournament { + + //local variable + public var refreshInProgress: Bool = false + public var lastTeamRefresh: Date? + public var refreshRanking: Bool = false + + func shouldRefreshTeams(forced: Bool) -> Bool { + if forced { + return true + } + guard let lastTeamRefresh else { return true } + return lastTeamRefresh.timeIntervalSinceNow < -600 + } + + @ObservationIgnored + public var navigationPath: [Screen] = [] + +// internal init(event: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = false, groupStageFormat: MatchFormat? = nil, roundFormat: MatchFormat? = nil, loserRoundFormat: MatchFormat? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType? = nil, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil, additionalEstimationDuration: Int = 0, isDeleted: Bool = false, publishTeams: Bool = false, publishSummons: Bool = false, publishGroupStages: Bool = false, publishBrackets: Bool = false, shouldVerifyBracket: Bool = false, shouldVerifyGroupStage: Bool = false, hideTeamsWeight: Bool = false, publishTournament: Bool = false, hidePointsEarned: Bool = false, publishRankings: Bool = false, loserBracketMode: LoserBracketMode = .automatic, initialSeedRound: Int = 0, initialSeedCount: Int = 0) { +// super.init() +// } + + + public init(event: String? = nil, name: String? = nil, startDate: Date = Date(), endDate: Date? = nil, creationDate: Date = Date(), isPrivate: Bool = true, groupStageFormat: MatchFormat? = nil, roundFormat: MatchFormat? = nil, loserRoundFormat: MatchFormat? = nil, groupStageSortMode: GroupStageOrderingMode, groupStageCount: Int = 4, rankSourceDate: Date? = nil, dayDuration: Int = 1, teamCount: Int = 24, teamSorting: TeamSortingType? = nil, federalCategory: TournamentCategory, federalLevelCategory: TournamentLevel, federalAgeCategory: FederalTournamentAge, closedRegistrationDate: Date? = nil, groupStageAdditionalQualified: Int = 0, courtCount: Int = 2, prioritizeClubMembers: Bool = false, qualifiedPerGroupStage: Int = 1, teamsPerGroupStage: Int = 4, entryFee: Double? = nil, additionalEstimationDuration: Int = 0, isDeleted: Bool = false, publishTeams: Bool = false, publishSummons: Bool = false, publishGroupStages: Bool = false, publishBrackets: Bool = false, shouldVerifyBracket: Bool = false, shouldVerifyGroupStage: Bool = false, hideTeamsWeight: Bool = false, publishTournament: Bool = false, hidePointsEarned: Bool = false, publishRankings: Bool = false, loserBracketMode: LoserBracketMode = .automatic, initialSeedRound: Int = 0, initialSeedCount: Int = 0, enableOnlineRegistration: Bool = false, registrationDateLimit: Date? = nil, openingRegistrationDate: Date? = nil, waitingListLimit: Int? = nil, accountIsRequired: Bool = true, licenseIsRequired: Bool = true, minimumPlayerPerTeam: Int = 2, maximumPlayerPerTeam: Int = 2, information: String? = nil, + umpireCustomMail: String? = nil, + umpireCustomContact: String? = nil, + umpireCustomPhone: String? = nil, + hideUmpireMail: Bool = false, + hideUmpirePhone: Bool = true, + disableRankingFederalRuling: Bool = false, + teamCountLimit: Bool = true + ) { + super.init() + self.event = event + self.name = name + self.startDate = startDate + self.endDate = endDate + self.creationDate = creationDate +#if DEBUG + self.isPrivate = false +#else + self.isPrivate = isPrivate +#endif + self.groupStageFormat = groupStageFormat + self.roundFormat = roundFormat + self.loserRoundFormat = loserRoundFormat + self.groupStageSortMode = groupStageSortMode + self.groupStageCount = groupStageCount + self.rankSourceDate = rankSourceDate + self.dayDuration = dayDuration + self.teamCount = teamCount + self.teamSorting = teamSorting ?? federalLevelCategory.defaultTeamSortingType + self.federalCategory = federalCategory + self.federalLevelCategory = federalLevelCategory + self.federalAgeCategory = federalAgeCategory + self.closedRegistrationDate = closedRegistrationDate + self.groupStageAdditionalQualified = groupStageAdditionalQualified + self.courtCount = courtCount + self.prioritizeClubMembers = prioritizeClubMembers + self.qualifiedPerGroupStage = qualifiedPerGroupStage + self.teamsPerGroupStage = teamsPerGroupStage + self.entryFee = entryFee + self.additionalEstimationDuration = additionalEstimationDuration + self.isDeleted = isDeleted +#if DEBUG + self.publishTeams = true + self.publishSummons = true + self.publishBrackets = true + self.publishGroupStages = true + self.publishRankings = true + self.publishTournament = true +#else + self.publishTeams = publishTeams + self.publishSummons = publishSummons + self.publishBrackets = publishBrackets + self.publishGroupStages = publishGroupStages + self.publishRankings = publishRankings + self.publishTournament = publishTournament +#endif + self.shouldVerifyBracket = shouldVerifyBracket + self.shouldVerifyGroupStage = shouldVerifyGroupStage + self.hideTeamsWeight = hideTeamsWeight + self.hidePointsEarned = hidePointsEarned + self.loserBracketMode = loserBracketMode + self.initialSeedRound = initialSeedRound + self.initialSeedCount = initialSeedCount + self.enableOnlineRegistration = enableOnlineRegistration + self.registrationDateLimit = registrationDateLimit + self.openingRegistrationDate = openingRegistrationDate + self.waitingListLimit = waitingListLimit + + self.accountIsRequired = accountIsRequired + self.licenseIsRequired = licenseIsRequired + self.minimumPlayerPerTeam = minimumPlayerPerTeam + self.maximumPlayerPerTeam = maximumPlayerPerTeam + self.information = information + self.umpireCustomMail = umpireCustomMail + self.umpireCustomContact = umpireCustomContact + self.disableRankingFederalRuling = disableRankingFederalRuling + self.teamCountLimit = teamCountLimit + } + + required init(from decoder: Decoder) throws { + try super.init(from: decoder) + } + + required public init() { + super.init() + } + + public var tournamentStore: TournamentStore? { + return TournamentLibrary.shared.store(tournamentId: self.id) + } + + public override func deleteDependencies(shouldBeSynchronized: Bool) { + guard let store = self.tournamentStore else { return } + + let drawLogs = Array(store.drawLogs) + for drawLog in drawLogs { + drawLog.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + store.drawLogs.deleteDependencies(drawLogs, shouldBeSynchronized: shouldBeSynchronized) + + let teams = Array(store.teamRegistrations) + for team in teams { + team.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + store.teamRegistrations.deleteDependencies(teams, shouldBeSynchronized: shouldBeSynchronized) + + let groups = Array(store.groupStages) + for group in groups { + group.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + store.groupStages.deleteDependencies(groups, shouldBeSynchronized: shouldBeSynchronized) + + let rounds = Array(store.rounds) + for round in rounds { + round.deleteDependencies(shouldBeSynchronized: shouldBeSynchronized) + } + store.rounds.deleteDependencies(rounds, shouldBeSynchronized: shouldBeSynchronized) + + store.matchSchedulers.deleteDependencies(self._matchSchedulers()) + + } + + // MARK: - Computed Dependencies + + public func unsortedTeams() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.teamRegistrations) + } + + public func unsortedTeamsCount() -> Int { + return self.tournamentStore?.teamRegistrations.count ?? 0 + } + + 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 } + return groupStages.sorted(by: \.index) + } + + public func allGroupStages() -> [GroupStage] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.groupStages.sorted(by: \GroupStage.computedOrder) + } + + public func allRounds() -> [Round] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.rounds) + } + + // MARK: - + + public enum State { + case initial + case build + case running + case canceled + case finished + } + + public func eventLabel() -> String { + if let event = eventObject(), let name = event.name { + return name + } else { + return "" + } + } + + public func publishedTournamentDate() -> Date { + return min(creationDate.tomorrowAtNine, startDate) + } + + public func publishedTeamsDate() -> Date { + return self.startDate + } + + func canBePublished() -> Bool { + switch state() { + case .build, .finished, .running: + return unsortedTeams().count > 3 + default: + return false + } + } + + public func isTournamentPublished() -> Bool { + return (Date() >= publishedTournamentDate()) || publishTournament + } + + public func areTeamsPublished() -> Bool { + return Date() >= startDate || publishTeams + } + + public func areSummonsPublished() -> Bool { + return Date() >= startDate || publishSummons + } + + fileprivate func _publishedDateFromMatches(_ matches: [Match]) -> Date? { + let startDates: [Date] = matches.compactMap { $0.startDate } + let sortedDates: [Date] = startDates.sorted() + + if let first: Date = sortedDates.first?.atEightAM() { + if first.isEarlierThan(startDate) { + return startDate + } else { + return first + } + } else { + return startDate + } + } + + public func publishedGroupStagesDate() -> Date? { + let matches: [Match] = self.groupStages().flatMap { $0.playedMatches() } + return self._publishedDateFromMatches(matches) + } + + public func areGroupStagesPublished() -> Bool { + if publishGroupStages { return true } + if let publishedGroupStagesDate = publishedGroupStagesDate() { + return Date() >= publishedGroupStagesDate + } else { + return false + } + } + + public func publishedBracketsDate() -> Date? { + let matches: [Match] = self.rounds().flatMap { $0.playedMatches() } + return self._publishedDateFromMatches(matches) + } + + public func areBracketsPublished() -> Bool { + if publishBrackets { return true } + if let publishedBracketsDate = publishedBracketsDate() { + return Date() >= publishedBracketsDate + } else { + return false + } + } + + public func shareURL(_ pageLink: PageLink = .matches) -> URL? { + if pageLink == .clubBroadcast { + let club = club() +// print("club", club) +// print("club broadcast code", club?.broadcastCode) + if let club, let broadcastCode = club.broadcastCode { + return URLs.main.url.appending(path: "c/\(broadcastCode)") + } else { + return nil + } + } + return URLs.main.url.appending(path: "tournament/\(id)").appending(path: pageLink.path) + } + + func courtUsed(runningMatches: [Match]) -> [Int] { +#if _DEBUGING_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func courtUsed()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + return Set(runningMatches.compactMap { $0.courtIndex }).sorted() + } + + public func hasStarted() -> Bool { + return startDate <= Date() + } + + public func eventObject() -> Event? { + guard let event else { return nil } + return Store.main.findById(event) + } + + public func pasteDataForImporting(_ exportFormat: ExportFormat = .rawText) -> String { + let _selectedSortedTeams = selectedSortedTeams() + let selectedSortedTeams = _selectedSortedTeams + waitingListSortedTeams(selectedSortedTeams: _selectedSortedTeams) + switch exportFormat { + case .rawText: + return (selectedSortedTeams.compactMap { $0.pasteData(exportFormat) } + ["Liste d'attente"] + waitingListTeams(in: selectedSortedTeams, includingWalkOuts: true).compactMap { $0.pasteData(exportFormat) }).joined(separator: exportFormat.newLineSeparator(2)) + case .csv: + let headers = ["N°", "Nom Prénom", "rang", "Nom Prénom", "rang", "poids"].joined(separator: exportFormat.separator()) + var teamPaste = [headers] + for (index, team) in selectedSortedTeams.enumerated() { + teamPaste.append(team.pasteData(exportFormat, index + 1)) + } + return teamPaste.joined(separator: exportFormat.newLineSeparator()) + } + } + + public func club() -> Club? { + return eventObject()?.clubObject() + } + + public func locationLabel(_ displayStyle: DisplayStyle = .wide) -> String { + if let club = club() { + switch displayStyle { + case .wide, .title: + return club.name + case .short: + return club.acronym + } + } else { + return "" + } + } + + public func hasEnded() -> Bool { + return endDate != nil + } + + public func state() -> State { + if self.isCanceled == true { + return .canceled + } + + if self.hasEnded() { return .finished } + + let isBuild = (groupStageCount > 0 && groupStages().isEmpty == false) + || rounds().isEmpty == false + + if isBuild && startDate <= Date() { return .running } + + if isBuild { + return .build + } + return .initial + } + + public func seededTeams() -> [TeamRegistration] { + return selectedSortedTeams().filter({ $0.bracketPosition != nil && $0.groupStagePosition == nil }) + } + + public func groupStageTeams() -> [TeamRegistration] { + return selectedSortedTeams().filter({ $0.groupStagePosition != nil }) + } + + public func groupStageSpots() -> Int { + return groupStages().map { $0.size }.reduce(0,+) + } + + public func seeds() -> [TeamRegistration] { + let selectedSortedTeams = selectedSortedTeams() + let seeds = max(selectedSortedTeams.count - groupStageSpots() , 0) + return Array(selectedSortedTeams.prefix(seeds)) + } + + public func availableSeeds() -> [TeamRegistration] { +#if _DEBUG_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func availableSeeds()", duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + + + return seeds().filter { $0.isSeedable() } + } + + func lastSeedRound() -> Int { + if let last = seeds().filter({ $0.bracketPosition != nil }).last { + return RoundRule.roundIndex(fromMatchIndex: last.bracketPosition! / 2) + } else { + return 0 + } + } + + func getRound(atRoundIndex roundIndex: Int) -> Round? { + return self.tournamentStore?.rounds.first(where: { $0.index == roundIndex }) +// return Store.main.filter(isIncluded: { $0.tournament == id && $0.index == roundIndex }).first + } + + func availableSeedSpot(inRoundIndex roundIndex: Int) -> [Match] { + return getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.isEmpty() } ?? [] + } + + func availableSeedOpponentSpot(inRoundIndex roundIndex: Int) -> [Match] { + return getRound(atRoundIndex: roundIndex)?.playedMatches().filter { $0.hasSpaceLeft() } ?? [] + } + public func availableSeedGroups(includeAll: Bool = false) -> [SeedInterval] { + let seeds = seeds() + var availableSeedGroup = Set() + for (index, seed) in seeds.enumerated() { + if seed.isSeedable(), let seedGroup = seedGroup(for: index) { + if includeAll { + if let chunks = seedGroup.chunks() { + chunksBy(in: chunks, availableSeedGroup: &availableSeedGroup) + } + } else { + availableSeedGroup.insert(seedGroup) + } + } + } + return availableSeedGroup.sorted(by: <) + } + + public func chunksBy(in chunks: [SeedInterval], availableSeedGroup: inout Set) { + chunks.forEach { chunk in + availableSeedGroup.insert(chunk) + if let moreChunk = chunk.chunks() { + self.chunksBy(in: moreChunk, availableSeedGroup: &availableSeedGroup) + } + } + } + + public func seedGroup(for alreadySetupSeeds: Int) -> SeedInterval? { + switch alreadySetupSeeds { + case 0...1: + return SeedInterval(first: 1, last: 2) + case 2...3: + return SeedInterval(first: 3, last: 4) + case 4...7: + return SeedInterval(first: 5, last: 8) + case 8...15: + return SeedInterval(first: 9, last: 16) + case 16...23: + return SeedInterval(first: 17, last: 24) + case 24...31: + return SeedInterval(first: 25, last: 32) + default: + return nil + } + } + + public func availableSeedGroup() -> SeedInterval? { + let seeds = seeds() + if let firstIndex = seeds.firstIndex(where: { $0.isSeedable() }) { + guard let seedGroup = seedGroup(for: firstIndex) else { return nil } + return seedGroup + } + return nil + } + + public func randomSeed(fromSeedGroup seedGroup: SeedInterval) -> TeamRegistration? { + let availableSeeds = seeds(inSeedGroup: seedGroup) + return availableSeeds.randomElement() + } + + public func seeds(inSeedGroup seedGroup: SeedInterval) -> [TeamRegistration] { + let availableSeedInSeedGroup = (seedGroup.last - seedGroup.first) + 1 + let availableSeeds = seeds().dropFirst(seedGroup.first - 1).prefix(availableSeedInSeedGroup).filter({ $0.isSeedable() }) + return availableSeeds + } + + public func seedGroupAvailable(atRoundIndex roundIndex: Int) -> SeedInterval? { + if let availableSeedGroup = availableSeedGroup() { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: availableSeedGroup) + } else { + return nil + } + } + + public func seedGroupAvailable(atRoundIndex roundIndex: Int, availableSeedGroup: SeedInterval) -> SeedInterval? { + let fullLeftSeeds = availableSeeds() + if fullLeftSeeds.isEmpty == false && roundIndex >= lastSeedRound() { + + if availableSeedGroup == SeedInterval(first: 1, last: 2) { return availableSeedGroup } + let availableSeeds = seeds(inSeedGroup: availableSeedGroup) + let availableSeedSpot = availableSeedSpot(inRoundIndex: roundIndex) + let availableSeedOpponentSpot = availableSeedOpponentSpot(inRoundIndex: roundIndex) + let targetSpots = availableSeedSpot.isEmpty ? availableSeedOpponentSpot.count : availableSeedSpot.count + if availableSeedGroup == SeedInterval(first: 3, last: 4), availableSeedSpot.count == 6 { + return availableSeedGroup + } + if availableSeeds.count == availableSeedSpot.count && availableSeedGroup.count == availableSeeds.count { + return availableSeedGroup + } else if availableSeeds.count == availableSeedOpponentSpot.count && availableSeedGroup.count == availableSeedOpponentSpot.count { + return availableSeedGroup + } else if let chunks = availableSeedGroup.chunks() { + let seededTeamsCount = self.seededTeams().count + if let chunk = chunks.first(where: { seedInterval in + return seedInterval.first == seededTeamsCount + }) { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: chunk) + } else if fullLeftSeeds.count > 1, targetSpots > 1, fullLeftSeeds.count >= targetSpots { + let currentSeeds = seeds() + if let firstIndex = currentSeeds.firstIndex(where: { $0.isSeedable() }) { + + if firstIndex < seededTeamsCount { + return nil + } else { + let sg = SeedInterval(first: seededTeamsCount + 1, last: seededTeamsCount + targetSpots) + let futureAvailableSeeds = self.seeds(inSeedGroup: sg) + if futureAvailableSeeds.count == targetSpots { + return seedGroupAvailable(atRoundIndex: roundIndex, availableSeedGroup: sg) + } else { + return nil + } + } + } + } + } + } + + return nil + } + + public func setSeeds(inRoundIndex roundIndex: Int, inSeedGroup seedGroup: SeedInterval) { + if seedGroup == SeedInterval(first: 1, last: 2) { + let seeds = seeds() + if let matches = getRound(atRoundIndex: roundIndex)?.playedMatches() { + if let lastMatch = matches.last { + seeds.prefix(1).first?.setSeedPosition(inSpot: lastMatch, slot: .two, opposingSeeding: false) + } + if let firstMatch = matches.first { + seeds.prefix(2).dropFirst().first?.setSeedPosition(inSpot: firstMatch, slot: .one, opposingSeeding: false) + } + } + } else { + + let availableSeedSpot = availableSeedSpot(inRoundIndex: roundIndex) + let availableSeedOpponentSpot = availableSeedOpponentSpot(inRoundIndex: roundIndex) + let availableSeeds = seeds(inSeedGroup: seedGroup) + + if seedGroup == SeedInterval(first: 3, last: 4), availableSeedSpot.count == 6 { + var spots = [Match]() + spots.append(availableSeedSpot[1]) + spots.append(availableSeedSpot[4]) + spots = spots.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) + } +// } else if seedGroup == SeedInterval(first: 5, last: 6), availableSeedSpot.count == 4 { +// var spots = [Match]() +// spots.append(availableSeedSpot[1]) +// spots.append(availableSeedSpot[2]) +// spots = spots.shuffled() +// for (index, seed) in availableSeeds.enumerated() { +// seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) +// } + } else { + if availableSeeds.count <= availableSeedSpot.count { + let spots = availableSeedSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: false) + } + } else if (availableSeeds.count <= availableSeedOpponentSpot.count && availableSeeds.count <= self.availableSeeds().count) { + + let spots = availableSeedOpponentSpot.shuffled() + for (index, seed) in availableSeeds.enumerated() { + seed.setSeedPosition(inSpot: spots[index], slot: nil, opposingSeeding: true) + } + } else if let chunks = seedGroup.chunks() { + if let chunk = chunks.first(where: { seedInterval in + seedInterval.first >= self.seededTeams().count + }) { + setSeeds(inRoundIndex: roundIndex, inSeedGroup: chunk) + } + } + } + } + } + + + public func inscriptionClosed() -> Bool { + closedRegistrationDate != nil + } + + public func getActiveGroupStage(atStep step: Int = 0) -> GroupStage? { + let groupStages = groupStages(atStep: step) + return groupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }).sorted(by: \.index).first ?? groupStages.first + } + + func matchesWithSpace() -> [Match] { + getActiveRound()?.playedMatches().filter({ $0.hasSpaceLeft() }) ?? [] + } + + public func getActiveRound(withSeeds: Bool = false) -> Round? { + let rounds: [Round] = self.rounds() + let unfinishedRounds: [Round] = rounds.filter { $0.hasStarted() && $0.hasEnded() == false } + let sortedRounds: [Round] = unfinishedRounds.sorted(by: \.index).reversed() + + let round = sortedRounds.first ?? rounds.last(where: { $0.hasEnded() }) ?? rounds.first + + if withSeeds { + if round?.seeds().isEmpty == false { + return round + } else { + return nil + } + } else { + return round + } + } + + public func getPlayedMatchDateIntervals(in event: Event) -> [DateInterval] { + let allMatches: [Match] = self.allMatches().filter { $0.courtIndex != nil && $0.startDate != nil } + return allMatches.map { match in + DateInterval(event: event.id, courtIndex: match.courtIndex!, startDate: match.startDate!, endDate: match.estimatedEndDate(additionalEstimationDuration)!) + } + } + + public func allRoundMatches() -> [Match] { + return allRounds().flatMap { $0._matches() } + } + + public func allMatches() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matches.filter { $0.disabled == false } + } + + func _allMatchesIncludingDisabled() -> [Match] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.matches) + } + + public func rounds() -> [Round] { + guard let tournamentStore = self.tournamentStore else { return [] } + let rounds: [Round] = tournamentStore.rounds.filter { $0.isUpperBracket() } + return rounds.sorted(by: \.index).reversed() + } + + public func sortedTeams(selectedSortedTeams: [TeamRegistration]) -> [TeamRegistration] { + let teams = selectedSortedTeams + return teams + waitingListTeams(in: teams, includingWalkOuts: true) + } + + public func waitingListSortedTeams(selectedSortedTeams: [TeamRegistration]) -> [TeamRegistration] { + let teams = selectedSortedTeams + return waitingListTeams(in: teams, includingWalkOuts: false) + } + + public func selectedSortedTeams() -> [TeamRegistration] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func selectedSortedTeams", id, tournamentTitle(), duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + var _sortedTeams : [TeamRegistration] = [] + var _teams = unsortedTeams().filter({ $0.isOutOfTournament() == false }) + + if let closedRegistrationDate { + _teams = _teams.filter({ team in + if let registrationDate = team.registrationDate { + return registrationDate <= closedRegistrationDate + } else { + return true + } + }) + } + + let defaultSorting : [MySortDescriptor] = _defaultSorting() + + let _completeTeams = _teams.sorted(using: defaultSorting, order: .ascending).filter { $0.isWildCard() == false }.prefix(teamCount).sorted(using: [.keyPath(\.initialWeight), .keyPath(\.id)], order: .ascending) + + let wcGroupStage = _teams.filter { $0.wildCardGroupStage }.sorted(using: _currentSelectionSorting, order: .ascending) + + let wcBracket = _teams.filter { $0.wildCardBracket }.sorted(using: _currentSelectionSorting, order: .ascending) + + let groupStageSpots: Int = self.groupStageSpots() + var bracketSeeds: Int = teamCount - groupStageSpots - wcBracket.count + var groupStageTeamCount: Int = groupStageSpots - wcGroupStage.count + if groupStageTeamCount < 0 { groupStageTeamCount = 0 } + if bracketSeeds < 0 { bracketSeeds = 0 } + + if prioritizeClubMembers { + + var bracketTeams: [TeamRegistration] = [] + bracketTeams.append(contentsOf: _completeTeams.filter { $0.hasMemberOfClub(clubName) }) + + let others: [TeamRegistration] = _completeTeams.filter { $0.hasMemberOfClub(clubName) == false } + let sortedOthers: [TeamRegistration] = others.sorted(using: defaultSorting, order: .ascending) + bracketTeams.append(contentsOf: sortedOthers) + + bracketTeams = bracketTeams + .prefix(bracketSeeds) + .sorted(using: _currentSelectionSorting, order: .ascending) + bracketTeams.append(contentsOf: wcBracket) + +// let bracketTeams: [TeamRegistration] = (_completeTeams.filter { $0.hasMemberOfClub(clubName) } + _completeTeams.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + + let groupStageTeamsNoFiltering = Set(_completeTeams).subtracting(bracketTeams) + let groupStageTeams = (groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) } + groupStageTeamsNoFiltering.filter { $0.hasMemberOfClub(clubName) == false }.sorted(using: defaultSorting, order: .ascending)).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } else { + let bracketTeams = _completeTeams.prefix(bracketSeeds).sorted(using: _currentSelectionSorting, order: .ascending) + wcBracket + let groupStageTeams = Set(_completeTeams).subtracting(bracketTeams).sorted(using: defaultSorting, order: .ascending).prefix(groupStageTeamCount).sorted(using: _currentSelectionSorting, order: .ascending) + wcGroupStage + _sortedTeams = bracketTeams.sorted(using: _currentSelectionSorting, order: .ascending) + groupStageTeams.sorted(using: _currentSelectionSorting, order: .ascending) + } + return _sortedTeams + } + + public func waitingListTeams(in teams: [TeamRegistration], includingWalkOuts: Bool) -> [TeamRegistration] { + let waitingList = Set(unsortedTeams()).subtracting(teams) + let waitings = waitingList.filter { $0.isOutOfTournament() == false }.sorted(using: _defaultSorting(), order: .ascending) + let walkOuts = waitingList.filter { $0.walkOut == true }.sorted(using: _defaultSorting(), order: .ascending) + if includingWalkOuts { + return waitings + walkOuts + } else { + return waitings + } + } + + public func bracketCut(teamCount: Int, groupStageCut: Int) -> Int { + return self.teamCount - groupStageCut + } + + public func groupStageCut() -> Int { + return groupStageSpots() + } + + public func cutLabel(index: Int, teamCount: Int?) -> String { + let _teamCount = teamCount ?? selectedSortedTeams().count + let groupStageCut = groupStageCut() + let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut) + + if index < bracketCut { + return "Tableau" + } else if index - bracketCut < groupStageCut && _teamCount > 0 { + return "Poule" + } else { + return "Attente" + } + } + + public func unsortedTeamsWithoutWO() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamRegistrations.filter { $0.isOutOfTournament() == false } + } + + public func walkoutTeams() -> [TeamRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.teamRegistrations.filter { $0.walkOut == true } +// return Store.main.filter { $0.tournament == self.id && $0.walkOut == true } + } + + public func duplicates(in players: [PlayerRegistration]) -> [PlayerRegistration] { + var duplicates = [PlayerRegistration]() + Set(players.compactMap({ $0.licenceId })).forEach { licenceId in + let found = players.filter({ $0.licenceId == licenceId }) + if found.count > 1 { + duplicates.append(found.first!) + } + } + return duplicates + } + + public func unsortedPlayers() -> [PlayerRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return Array(tournamentStore.playerRegistrations) + } + + public func selectedPlayers() -> [PlayerRegistration] { + return self.selectedSortedTeams().flatMap { $0.unsortedPlayers() }.sorted(by: \.computedRank) + } + + public func paidSelectedPlayers(type: PlayerPaymentType) -> Double? { + if let entryFee { + let flat = self.selectedSortedTeams().flatMap { $0.unsortedPlayers() } + let count = flat.filter { $0.paymentType == type }.count + return Double(count) * entryFee + } else { + return nil + } + } + + public func players() -> [PlayerRegistration] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.playerRegistrations.sorted(by: \.computedRank) + } + + public func unrankValue(for malePlayer: Bool) -> Int? { + switch tournamentCategory { + case .unlisted: + return nil + case .men: + return maleUnrankedValue + case .women: + return femaleUnrankedValue + case .mix: + return malePlayer ? maleUnrankedValue : femaleUnrankedValue + } + } + + //todo + public var clubName: String? { + return self.eventObject()?.clubObject()?.name + } + + //todo + public func significantPlayerCount() -> Int { + return minimumPlayerPerTeam + } + + public func licenseYearValidity() -> Int { + if startDate.get(.month) > 8 { + return startDate.get(.year) + 1 + } else { + return startDate.get(.year) + } + } + + + public func getStartDate(ofSeedIndex seedIndex: Int?) -> Date? { + guard let seedIndex else { return nil } + return selectedSortedTeams()[safe: seedIndex]?.callDate + } + + public func maximumCourtsPerGroupSage() -> Int { + if teamsPerGroupStage > 1 { + return min(teamsPerGroupStage / 2, courtCount) + } else { + return max(1, courtCount) + } + } + + public func isStartDateIsDifferentThanCallDate(_ team: TeamRegistration, expectedSummonDate: Date? = nil) -> Bool { + guard let summonDate = team.callDate else { return true } + let expectedSummonDate : Date? = team.expectedSummonDate() ?? expectedSummonDate + guard let expectedSummonDate else { return true } + return Calendar.current.compare(summonDate, to: expectedSummonDate, toGranularity: .minute) != ComparisonResult.orderedSame + } + + public func groupStagesMatches(atStep step: Int = 0) -> [Match] { + return groupStages(atStep: step).flatMap({ $0._matches() }) +// return Store.main.filter(isIncluded: { $0.groupStage != nil && groupStageIds.contains($0.groupStage!) }) + } + + static let defaultSorting : [MySortDescriptor] = [.keyPath(\Match.computedStartDateForSorting), .keyPath(\Match.computedOrder)] + + public static func availableToStart(_ allMatches: [Match], in runningMatches: [Match], checkCanPlay: Bool = true) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament availableToStart", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() == false && $0.canBeStarted(inMatches: runningMatches, checkCanPlay: checkCanPlay) }).sorted(using: defaultSorting, order: .ascending) + } + + public static func runningMatches(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament runningMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() && $0.isReady() }).sorted(using: defaultSorting, order: .ascending) + } + + public static func readyMatches(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false }).sorted(using: defaultSorting, order: .ascending) + } + + public static func matchesLeft(_ allMatches: [Match]) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + return allMatches.filter({ $0.isRunning() == false && $0.hasEnded() == false }).sorted(using: defaultSorting, order: .ascending) + } + + + public static func finishedMatches(_ allMatches: [Match], limit: Int?) -> [Match] { + #if _DEBUG_TIME //DEBUGING TIME + let start = Date() + defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func tournament finishedMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) + } + #endif + if let limit { + return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed().prefix(limit)) + } else { + return Array(allMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed()) + } + } + + private func _removeStrings(from dictionary: inout [Int: [String]], stringsToRemove: [String]) { + for key in dictionary.keys { + if var stringArray = dictionary[key] { + // Remove all instances of each string in stringsToRemove + stringArray.removeAll { stringsToRemove.contains($0) } + dictionary[key] = stringArray + } + } + } + + + public func finalRanking() async -> [Int: [String]] { + var teams: [Int: [String]] = [:] + var ids: Set = Set() + let rounds = rounds() + let lastStep = lastStep() + if rounds.isEmpty, lastStep > 0 { + let groupStages = groupStages(atStep: lastStep) + + for groupStage in groupStages { + let groupStageTeams = groupStage.teams(true) + for teamIndex in 0.. qualifiedPerGroupStage ? groupStageAdditionalQualified : 0) + print("finalRanking", team.teamLabel() , _index, baseRank, groupStageWidth) + if let existingTeams = teams[_index] { + teams[_index] = existingTeams + [team.id] + } else { + teams[_index] = [team.id] + } + } + } + } + } + } + + return teams + } + + public func setRankings(assimilationLevel: TournamentLevel? = nil, finalRanks: [Int: [String]]) async -> [Int: [TeamRegistration]] { + guard let tournamentStore = self.tournamentStore else { return [:] } + let tournamentLevel = assimilationLevel ?? tournamentLevel + var rankings: [Int: [TeamRegistration]] = [:] + + finalRanks.keys.sorted().forEach { rank in + if let rankedTeamIds = finalRanks[rank] { + let teams: [TeamRegistration] = rankedTeamIds.compactMap { tournamentStore.teamRegistrations.findById($0) } + rankings[rank] = teams + } + } + + rankings.keys.sorted().forEach { rank in + if let rankedTeams = rankings[rank] { + rankedTeams.forEach { team in + team.finalRanking = rank + team.pointsEarned = isAnimation() ? nil : tournamentLevel.points(for: rank - 1, count: teamCount) + } + } + } + + do { + try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams()) + } catch { + Logger.error(error) + } + + if self.publishRankings == false { + self.publishRankings = true + do { + try DataStore.shared.tournaments.addOrUpdate(instance: self) + } catch { + Logger.error(error) + } + } + + return rankings + } + + public func lockRegistration() { + closedRegistrationDate = Date() + let count = selectedSortedTeams().count + if teamCount != count { + teamCount = count + } + let teams = unsortedTeams() + teams.forEach { team in + team.lockedWeight = team.weight + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + public func unlockRegistration() { + closedRegistrationDate = nil + let teams = unsortedTeams() + teams.forEach { team in + team.lockedWeight = nil + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + public func missingUnrankedValue() -> Bool { + return maleUnrankedValue == nil || femaleUnrankedValue == nil + } + + public func findTeam(_ players: [PlayerRegistration]) -> TeamRegistration? { + return unsortedTeams().first(where: { $0.includes(players: players) }) + } + + public func tournamentTitle(_ displayStyle: DisplayStyle = .wide, hideSenior: Bool = false) -> String { + if tournamentLevel == .unlisted, displayStyle == .title { + if let name { + return name + } else { + return tournamentLevel.localizedLevelLabel(.title) + } + } + let displayStyleCategory = hideSenior ? .short : displayStyle + var levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle), tournamentCategory.localizedLabel(displayStyle)] + if displayStyle == .short { + levelCategory = [tournamentLevel.localizedLevelLabel(displayStyle) + tournamentCategory.localizedLabel(displayStyle)] + } + let array = levelCategory + [federalTournamentAge.localizedFederalAgeLabel(displayStyleCategory)] + let title: String = array.filter({ $0.isEmpty == false }).joined(separator: " ") + if displayStyle == .wide, let name { + return [title, name].joined(separator: " - ") + } else { + return title + } + } + + public func localizedTournamentType() -> String { + switch tournamentLevel { + case .unlisted: + return tournamentLevel.localizedLevelLabel(.short) + default: + return tournamentLevel.localizedLevelLabel(.short) + tournamentCategory.localizedLabel(.short) + } + } + + public func hideWeight() -> Bool { + return hideTeamsWeight + } + + public func isAnimation() -> Bool { + federalLevelCategory.isAnimation() + } + + public func subtitle(_ displayStyle: DisplayStyle = .wide) -> String { + return name ?? "" + } + + 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) + } + } + + public func qualifiedFromGroupStage() -> Int { + return groupStageCount * qualifiedPerGroupStage + } + + public func availableQualifiedTeams() -> [TeamRegistration] { +#if _DEBUG_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func availableQualifiedTeams()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + + return unsortedTeams().filter({ $0.qualified && $0.bracketPosition == nil }) + } + + public func qualifiedTeams() -> [TeamRegistration] { + return unsortedTeams().filter({ $0.qualified }) + } + + public func moreQualifiedToDraw() -> Int { + return max((qualifiedFromGroupStage() + groupStageAdditionalQualified) - qualifiedTeams().count, 0) + } + + public func missingQualifiedFromGroupStages() -> [TeamRegistration] { + if groupStageAdditionalQualified > 0 && groupStagesAreOver() { + return groupStages().filter { $0.hasEnded() }.compactMap { groupStage in + groupStage.teams(true)[safe: qualifiedPerGroupStage] + } + .filter({ $0.qualified == false }) + } else { + return [] + } + } + + public func groupStagesAreOver(atStep: Int = 0) -> Bool { + let groupStages = groupStages(atStep: atStep) + guard groupStages.isEmpty == false else { + return true + } + return groupStages.allSatisfy({ $0.hasEnded() }) + //return qualifiedTeams().count == qualifiedFromGroupStage() + groupStageAdditionalQualified + } + + func groupStageLoserBracketAreOver() -> Bool { + guard let groupStageLoserBracket = groupStageLoserBracket() else { + return true + } + return groupStageLoserBracket.hasEnded() + } + + fileprivate func _paymentMethodMessage() -> String? { + return DataStore.shared.user.summonsAvailablePaymentMethods ?? ContactType.defaultAvailablePaymentMethods + } + + public var entryFeeMessage: String { + if let entryFee { + let message: String = "Inscription : \(entryFee.formatted(.currency(code: Locale.defaultCurrency()))) par joueur." + return [message, self._paymentMethodMessage()].compactMap { $0 }.joined(separator: "\n") + } else { + return "Inscription : gratuite." + } + } + + public func umpireMail() -> [String]? { + return [umpireCustomMail ?? DataStore.shared.user.email] + } + + public func earnings() -> Double { + if let entryFee { + return Double(selectedPlayers().filter { $0.hasPaid() }.count) * entryFee + } else { + return 0.0 + } + } + + public func paidCompletion() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasPaid() }.count) / Double(selectedPlayers.count) + } + + public func presenceStatus() -> Double { + let selectedPlayers = selectedPlayers() + if selectedPlayers.isEmpty { return 0 } + return Double(selectedPlayers.filter { $0.hasArrived }.count) / Double(selectedPlayers.count) + } + + public typealias TournamentStatus = (label:String, completion: String) + + public func cashierStatus() async -> TournamentStatus { + let selectedPlayers = selectedPlayers() + var filteredPlayers = [PlayerRegistration]() + var wording = "" + if isFree() { + wording = "présent" + filteredPlayers = selectedPlayers.filter({ $0.hasArrived }) + } else { + wording = "encaissé" + filteredPlayers = selectedPlayers.filter({ $0.hasPaid() }) + } +// let label = paid.count.formatted() + " / " + selectedPlayers.count.formatted() + " joueurs encaissés" + let label = "\(filteredPlayers.count.formatted()) / \(selectedPlayers.count.formatted()) joueurs \(wording)\(filteredPlayers.count.pluralSuffix)" + let completion = (Double(filteredPlayers.count) / Double(selectedPlayers.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + public func scheduleStatus() async -> TournamentStatus { + let allMatches = allMatches() + let ready = allMatches.filter({ $0.startDate != nil }) +// let label = ready.count.formatted() + " / " + allMatches.count.formatted() + " matchs programmés" + let label = "\(ready.count.formatted()) / \(allMatches.count.formatted()) matchs programmés" + let completion = (Double(ready.count) / Double(allMatches.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + public func callStatus() async -> TournamentStatus { + let selectedSortedTeams = selectedSortedTeams() + let called = selectedSortedTeams.filter { isStartDateIsDifferentThanCallDate($0) == false } + let justCalled = selectedSortedTeams.filter { $0.called() } + + let label = "\(justCalled.count.formatted()) / \(selectedSortedTeams.count.formatted()) (\(called.count.formatted()) au bon horaire)" + let completion = (Double(called.count) / Double(selectedSortedTeams.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + public func confirmedSummonStatus() async -> TournamentStatus { + let selectedSortedTeams = selectedSortedTeams() + let called = selectedSortedTeams.filter { $0.confirmationDate != nil } + let label = "\(called.count.formatted()) / \(selectedSortedTeams.count.formatted()) confirmées" + let completion = (Double(called.count) / Double(selectedSortedTeams.count)) + let completionLabel = completion.isNaN ? "" : completion.formatted(.percent.precision(.fractionLength(0))) + return TournamentStatus(label: label, completion: completionLabel) + } + + public func bracketStatus() async -> (status: String, description: String?, cut: TeamRegistration.TeamRange?) { + let availableSeeds = availableSeeds() + var description: String? = nil + if availableSeeds.isEmpty == false { + description = "placer \(availableSeeds.count) équipe\(availableSeeds.count.pluralSuffix)" + } + if description == nil { + let availableQualifiedTeams = availableQualifiedTeams() + if availableQualifiedTeams.isEmpty == false { + description = "placer \(availableQualifiedTeams.count) qualifié" + availableQualifiedTeams.count.pluralSuffix + } + } + + var cut: TeamRegistration.TeamRange? = nil + if description == nil && isAnimation() == false { + cut = TeamRegistration.TeamRange(availableSeeds.first, availableSeeds.last) + } + + if let round = getActiveRound() { + return ([round.roundTitle(.short), round.roundStatus()].joined(separator: " ").lowercased(), description, cut) + } else { + return ("", description, nil) + } + } + + public func groupStageStatus() async -> (status: String, cut: TeamRegistration.TeamRange?) { + let groupStageTeams = groupStageTeams() + let groupStageTeamsCount = groupStageTeams.count + if groupStageTeamsCount == 0 || groupStageTeamsCount != groupStageSpots() { + return ("à compléter", nil) + } + + let cut : TeamRegistration.TeamRange? = isAnimation() ? nil : TeamRegistration.TeamRange(groupStageTeams.first, groupStageTeams.last) + + let runningGroupStages = groupStages().filter({ $0.isRunning() }) + if groupStagesAreOver() { return ("terminées", cut) } + if runningGroupStages.isEmpty { + + let ongoingGroupStages = runningGroupStages.filter({ $0.hasStarted() && $0.hasEnded() == false }) + if ongoingGroupStages.isEmpty == false { + return ("Poule" + ongoingGroupStages.count.pluralSuffix + " " + ongoingGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) + } + return (groupStages().count.formatted() + " poule" + groupStages().count.pluralSuffix, cut) + } else { + return ("Poule" + runningGroupStages.count.pluralSuffix + " " + runningGroupStages.map { ($0.index + 1).formatted() }.joined(separator: ", ") + " en cours", cut) + } + } + + public func settingsDescriptionLocalizedLabel() -> String { + [courtCount.formatted() + " terrain\(courtCount.pluralSuffix)", entryFeeMessage].joined(separator: ", ") + } + + public func structureDescriptionLocalizedLabel() -> String { + let groupStageLabel: String? = groupStageCount > 0 ? groupStageCount.formatted() + " poule\(groupStageCount.pluralSuffix)" : nil + return [teamCount.formatted() + " équipes", groupStageLabel].compactMap({ $0 }).joined(separator: ", ") + } + + public func deleteAndBuildEverything(preset: PadelTournamentStructurePreset = .manual) { + resetBracketPosition() + deleteStructure() + deleteGroupStages() + + switch preset { + case .doubleGroupStage: + buildGroupStages() + addNewGroupStageStep() + qualifiedPerGroupStage = 0 + groupStageAdditionalQualified = 0 + default: + buildGroupStages() + buildBracket() + } + } + + public func buildGroupStages() { + guard groupStages().isEmpty, let tournamentStore = self.tournamentStore else { + return + } + + var _groupStages = [GroupStage]() + for index in 0.. Int { + let bracketTeamCount = teamCount - (teamsPerGroupStage - qualifiedPerGroupStage) * groupStageCount + (groupStageAdditionalQualified * (groupStageCount > 0 ? 1 : 0)) + return bracketTeamCount + } + + public func buildBracket(minimalBracketTeamCount: Int? = nil) { + guard rounds().isEmpty else { return } + let roundCount = RoundRule.numberOfRounds(forTeams: minimalBracketTeamCount ?? bracketTeamCount()) + let matchCount = RoundRule.numberOfMatches(forTeams: minimalBracketTeamCount ?? bracketTeamCount()) + + let rounds = (0.. Match? { + guard let bracketPosition else { return nil } + let matchIndex = bracketPosition / 2 + let roundIndex = RoundRule.roundIndex(fromMatchIndex: matchIndex) + if let round: Round = self.getRound(atRoundIndex: roundIndex) { + return self.tournamentStore?.matches.first(where: { $0.round == round.id && $0.index == matchIndex }) +// return Store.main.filter(isIncluded: { $0.round == round.id && $0.index == matchIndex }).first + + } + return nil + } + + public func resetTeamScores(in matchOfBracketPosition: Int?, outsideOf: [TeamScore] = []) { + guard let match = match(for: matchOfBracketPosition) else { return } + match.resetTeamScores(outsideOf: outsideOf) + } + + public func updateTeamScores(in matchOfBracketPosition: Int?) { + guard let match = match(for: matchOfBracketPosition) else { return } + match.updateTeamScores() + } + + public func deleteStructure() { + self.tournamentStore?.rounds.delete(contentOfs: rounds()) + } + + public func resetBracketPosition() { + unsortedTeams().forEach({ $0.bracketPosition = nil }) + } + + public func deleteGroupStages() { + self.tournamentStore?.groupStages.delete(contentOfs: allGroupStages()) + } + + public func refreshGroupStages(keepExistingMatches: Bool = false) { + unsortedTeams().forEach { team in + team.groupStage = nil + team.groupStagePosition = nil + } + + if groupStageCount > 0 { + switch groupStageOrderingMode { + case .random: + setGroupStage(randomize: true, keepExistingMatches: keepExistingMatches) + case .snake: + setGroupStage(randomize: false, keepExistingMatches: keepExistingMatches) + case .swiss: + setGroupStage(randomize: true, keepExistingMatches: keepExistingMatches) + } + } + } + + public func setGroupStage(randomize: Bool, keepExistingMatches: Bool = false) { + let groupStages = groupStages() + let numberOfBracketsAsInt = groupStages.count +// let teamsPerBracket = teamsPerBracket + if groupStageCount != numberOfBracketsAsInt { + deleteGroupStages() + buildGroupStages() + } else { + setGroupStageTeams(randomize: randomize) + groupStages.forEach { $0.buildMatches(keepExistingMatches: keepExistingMatches) } + } + } + + public func removeWildCards() { + let wcs = unsortedTeams().filter({ $0.isWildCard() && $0.unsortedPlayers().isEmpty }) + do { + try tournamentStore?.teamRegistrations.delete(contentOfs: wcs) + } catch { + Logger.error(error) + } + } + + public func setGroupStageTeams(randomize: Bool) { + let groupStages = groupStages() + let max = groupStages.map { $0.size }.reduce(0,+) + var chunks = selectedSortedTeams().filter({ $0.wildCardBracket == false }).suffix(max).chunked(into: groupStageCount) + for (index, _) in chunks.enumerated() { + if randomize { + chunks[index].shuffle() + } else if index % 2 != 0 { + chunks[index].reverse() + } + + print("Equipes \(chunks[index].map { $0.weight })") + for (jIndex, _) in chunks[index].enumerated() { + print("Position \(index + 1) Poule \(groupStages[jIndex].index)") + chunks[index][jIndex].groupStage = groupStages[jIndex].id + chunks[index][jIndex].groupStagePosition = index + } + } + + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams()) + } + + public func isFree() -> Bool { + return entryFee == nil || entryFee == 0 + } + + public func indexOf(team: TeamRegistration) -> Int? { + return selectedSortedTeams().firstIndex(where: { $0.id == team.id }) + } + + public func labelIndexOf(team: TeamRegistration) -> String? { + if let teamIndex = indexOf(team: team) { + return "Tête de série #" + (teamIndex + 1).formatted() + } else { + return nil + } + } + + public var matchFormat: MatchFormat { + get { + roundFormat ?? .defaultFormatForMatchType(.bracket) + } + set { + roundFormat = newValue + } + } + + public var groupStageMatchFormat: MatchFormat { + get { + groupStageFormat ?? .defaultFormatForMatchType(.groupStage) + } + set { + groupStageFormat = newValue + } + } + + public var loserBracketMatchFormat: MatchFormat { + get { + loserRoundFormat ?? .defaultFormatForMatchType(.loserBracket) + } + set { + loserRoundFormat = newValue + } + } + + public var groupStageOrderingMode: GroupStageOrderingMode { + get { + groupStageSortMode + } + set { + groupStageSortMode = newValue + } + } + + public var tournamentLevel: TournamentLevel { + get { + federalLevelCategory + } + set { + federalLevelCategory = newValue + teamSorting = newValue.defaultTeamSortingType + groupStageMatchFormat = groupStageSmartMatchFormat() + loserBracketMatchFormat = loserBracketSmartMatchFormat(1) + matchFormat = roundSmartMatchFormat(5) + } + } + + public var federalTournamentAge: FederalTournamentAge { + get { + federalAgeCategory + } + set { + federalAgeCategory = newValue + } + } + + public func loserBracketSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { + let format = tournamentLevel.federalFormatForLoserBracketRound(roundIndex) + if tournamentLevel == .p25 { return .superTie } + if format.rank < loserBracketMatchFormat.rank { + return format + } else { + return loserBracketMatchFormat + } + } + + public func groupStageSmartMatchFormat() -> MatchFormat { + let format = tournamentLevel.federalFormatForGroupStage() + if tournamentLevel == .p25 { return .superTie } + if format.rank < groupStageMatchFormat.rank { + return format + } else { + return groupStageMatchFormat + } + } + + public func onlineRegistrationCanBeEnabled() -> Bool { + isAnimation() == false + } + + public func roundSmartMatchFormat(_ roundIndex: Int) -> MatchFormat { + let format = tournamentLevel.federalFormatForBracketRound(roundIndex) + if tournamentLevel == .p25 { return .superTie } + if format.rank < matchFormat.rank { + return format + } else { + return matchFormat + } + } + + private func _defaultSorting() -> [MySortDescriptor] { + switch teamSorting { + case .rank: + [.keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.id)] + case .inscriptionDate: + [.keyPath(\TeamRegistration.registrationDate!), .keyPath(\TeamRegistration.initialWeight), .keyPath(\TeamRegistration.id)] + } + } + + func isSameBuild(_ build: any TournamentBuildHolder) -> Bool { + tournamentLevel == build.level + && tournamentCategory == build.category + && federalTournamentAge == build.age + } + + private let _currentSelectionSorting : [MySortDescriptor] = [.keyPath(\.weight), .keyPath(\.id)] + + private func _matchSchedulers() -> [MatchScheduler] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.matchSchedulers.filter { $0.tournament == self.id } +// DataStore.shared.matchSchedulers.filter(isIncluded: { $0.tournament == self.id }) + } + + public func matchScheduler() -> MatchScheduler? { + return self._matchSchedulers().first + } + + public func courtsAvailable() -> [Int] { + (0.. MonthData? { + guard let rankSourceDate else { return nil } + let dateString = URL.importDateFormatter.string(from: rankSourceDate) + return DataStore.shared.monthData.first(where: { $0.monthKey == dateString }) + } + + public var maleUnrankedValue: Int? { + return currentMonthData()?.maleUnrankedValue + } + + public var femaleUnrankedValue: Int? { + return currentMonthData()?.femaleUnrankedValue + } + + public func courtNameIfAvailable(atIndex courtIndex: Int) -> String? { + return club()?.customizedCourts.first(where: { $0.index == courtIndex })?.name + } + + public func courtName(atIndex courtIndex: Int) -> String { + return courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) + } + + public func tournamentWinner() -> TeamRegistration? { + let finals: Round? = self.tournamentStore?.rounds.first(where: { $0.index == 0 && $0.isUpperBracket() }) + return finals?.playedMatches().first?.winner() + } + + public func getGroupStageChunkValue() -> Int { + if groupStageCount > 0 && teamsPerGroupStage >= 2 { + let result = courtCount / (teamsPerGroupStage / 2) + let remainder = courtCount % (teamsPerGroupStage / 2) + let value = remainder == 0 ? result : result + 1 + return min(groupStageCount, value) + } else { + return 1 + } + } + + public func replacementRangeExtended(groupStagePosition: Int) -> TeamRegistration.TeamRange? { + let selectedSortedTeams = selectedSortedTeams() + var left: TeamRegistration? = nil + if groupStagePosition == 0 { + left = seeds().last + } else { + let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition - 1 }).sorted(by: \.weight) + left = previousHat.last + } + var right: TeamRegistration? = nil + if groupStagePosition == teamsPerGroupStage - 1 { + right = nil + } else { + let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition + 1 }).sorted(by: \.weight) + right = previousHat.first + } + return (left: left, right: right) + } + + + public typealias TeamPlacementIssue = (shouldBeInIt: [String], shouldNotBeInIt: [String]) + public func groupStageTeamPlacementIssue() -> TeamPlacementIssue { + let selected = selectedSortedTeams() + let allTeams = unsortedTeams() + let newGroup = selected.suffix(groupStageSpots()) + let currentGroup = allTeams.filter({ $0.groupStagePosition != nil }) + let selectedIds = newGroup.map { $0.id } + let groupIds = currentGroup.map { $0.id } + let shouldBeInIt = Set(selectedIds).subtracting(groupIds) + let shouldNotBeInIt = Set(groupIds).subtracting(selectedIds) + return (Array(shouldBeInIt), Array(shouldNotBeInIt)) + } + + public func bracketTeamPlacementIssue() -> TeamPlacementIssue { + let selected = selectedSortedTeams() + let allTeams = unsortedTeams() + let seedCount = max(selected.count - groupStageSpots(), 0) + let newGroup = selected.prefix(seedCount) + selected.filter({ $0.qualified }) + let currentGroup = allTeams.filter({ $0.bracketPosition != nil }) + let selectedIds = newGroup.map { $0.id } + let groupStageTeamsInBracket = selected.filter({ $0.qualified == false && $0.inGroupStage() && $0.inRound() }) + let groupIds = currentGroup.map { $0.id } + groupStageTeamsInBracket.map { $0.id } + let shouldBeInIt = Set(selectedIds).subtracting(groupIds) + let shouldNotBeInIt = Set(groupIds).subtracting(selectedIds) + return (Array(shouldBeInIt), Array(shouldNotBeInIt)) + } + + public func groupStageLoserBracket() -> Round? { + self.tournamentStore?.rounds.first(where: { $0.groupStageLoserBracket }) + } + + public func groupStageLoserBracketsInitialPlace() -> Int { + return teamCount - teamsPerGroupStage * groupStageCount + qualifiedPerGroupStage * groupStageCount + groupStageAdditionalQualified + 1 + } + + public func addNewGroupStageStep() { + let lastStep = lastStep() + 1 + for i in 0.. Int { + self.tournamentStore?.groupStages.sorted(by: \.step).last?.step ?? 0 + } + + public func generateSmartLoserGroupStageBracket() { + guard let groupStageLoserBracket = groupStageLoserBracket() else { return } + for i in qualifiedPerGroupStage.. [Match] { + rounds().flatMap { $0.loserRoundsAndChildren().flatMap({ $0._matches() }) } + } + + public func seedsCount() -> Int { + selectedSortedTeams().count - groupStageSpots() + } + + public func lastDrawnDate() -> Date? { + drawLogs().last?.drawDate + } + + public func drawLogs() -> [DrawLog] { + guard let tournamentStore = self.tournamentStore else { return [] } + return tournamentStore.drawLogs.sorted(by: \.drawDate) + } + + public func seedSpotsLeft() -> Bool { + let alreadySeededRounds = rounds().filter({ $0.seeds().isEmpty == false }) + if alreadySeededRounds.isEmpty { return true } + let spotsLeft = alreadySeededRounds.flatMap({ $0.playedMatches() }).filter { $0.isEmpty() || $0.isValidSpot() } + + return spotsLeft.isEmpty == false + } + + public func isRoundValidForSeeding(roundIndex: Int) -> Bool { + if let lastRoundWithSeeds = rounds().last(where: { $0.seeds().isEmpty == false }) { + return roundIndex >= lastRoundWithSeeds.index + } else { + return true + } + } + + + public func updateSeedsBracketPosition() async { + await removeAllSeeds(saveTeamsAtTheEnd: false) + let drawLogs = drawLogs().reversed() + let seeds = seeds() + + await MainActor.run { + for (index, seed) in seeds.enumerated() { + if let drawLog = drawLogs.first(where: { $0.drawSeed == index }) { + drawLog.updateTeamBracketPosition(seed) + } + } + } + + do { + try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: seeds) + } catch { + Logger.error(error) + } + } + + public func removeAllSeeds(saveTeamsAtTheEnd: Bool) async { + let teams = unsortedTeams() + teams.forEach({ team in + team.bracketPosition = nil + team._cachedRestingTime = nil + team.finalRanking = nil + team.pointsEarned = nil + }) + let allMatches = allRoundMatches() + let ts = allMatches.flatMap { match in + match.teamScores + } + allMatches.forEach { match in + match.disabled = false + match.losingTeamId = nil + match.winningTeamId = nil + match.endDate = nil + match.removeCourt() + match.servingTeamId = nil + } + + do { + try tournamentStore?.teamScores.delete(contentOfs: ts) + } catch { + Logger.error(error) + } + + if saveTeamsAtTheEnd { + do { + try tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } catch { + Logger.error(error) + } + } + } + + public func addNewRound(_ roundIndex: Int) async { + let round = Round(tournament: id, index: roundIndex, matchFormat: matchFormat) + let matchCount = RoundRule.numberOfMatches(forRoundIndex: roundIndex) + let matchStartIndex = RoundRule.matchIndex(fromRoundIndex: roundIndex) + let nextRound = round.nextRound() + var currentIndex = 0 + let matches = (0.. String { + var logs : [String] = ["Journal des tirages\n\n"] + logs.append(drawLogs().map { $0.exportedDrawLog() }.joined(separator: "\n\n")) + return logs.joined() + } + + + public func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date) -> Bool { + guard let source = eventObject()?.courtsUnavailability else { return false } + let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex }) + return courtLockedSchedule.anySatisfy({ dateInterval in + let range = startDate.. OnlineRegistrationStatus { + if hasStarted() { + return .inProgress + } + if closedRegistrationDate != nil { + return .ended + } + if endDate != nil { + return .endedWithResults + } + + let now = Date() + + if let openingRegistrationDate = openingRegistrationDate { + let timezonedDateTime = openingRegistrationDate // Assuming dates are already in local timezone + if now < timezonedDateTime { + return .notStarted + } + } + + if let registrationDateLimit = registrationDateLimit { + let timezonedDateTime = registrationDateLimit // Assuming dates are already in local timezone + if now > timezonedDateTime { + return .ended + } + } + + let currentTeamCount = unsortedTeamsWithoutWO().count + + if currentTeamCount >= teamCount { + if let waitingListLimit = waitingListLimit { + let waitingListCount = currentTeamCount - teamCount + if waitingListCount >= waitingListLimit { + return .waitingListFull + } + } + return .waitingListPossible + } + + return .open + } + + // MARK: - Status + public func shouldTournamentBeOver() async -> Bool { + return false + if tournamentStore?.store.fileCollectionsAllLoaded() == false { + return false + } +#if _DEBUGING_TIME //DEBUGING TIME +let start = Date() +defer { + let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) + print("func shouldTournamentBeOver()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) +} +#endif + if isDeleted == false && hasEnded() == false && hasStarted() { + let allMatches = allMatches() + let remainingMatches = allMatches.filter({ $0.hasEnded() == false && $0.startDate != nil }) + + let calendar = Calendar.current + let anyTomorrow = remainingMatches.anySatisfy({ calendar.isDateInTomorrow($0.startDate!) }) + + + if anyTomorrow == false, let endDate = allMatches.filter({ $0.hasEnded() }).sorted(by: \.endDate!, order: .ascending).last?.endDate, endDate.timeIntervalSinceNow <= -2 * 3600 { + return true + } + } + + return false + } + + public func rankSourceShouldBeRefreshed() -> Date? { + if let mostRecentDate = SourceFileManager.shared.lastDataSourceDate(), let currentRankSourceDate = rankSourceDate, currentRankSourceDate < mostRecentDate, hasEnded() == false { + return mostRecentDate + } else { + return nil + } + } + + public func onlineTeams() -> [TeamRegistration] { + unsortedTeams().filter({ $0.hasRegisteredOnline() }) + } + + public func shouldWarnOnlineRegistrationUpdates() -> Bool { + enableOnlineRegistration && onlineTeams().isEmpty == false && hasEnded() == false && hasStarted() == false + } + + public func refreshTeamList(forced: Bool) async { + guard StoreCenter.main.isAuthenticated else { return } + guard tournamentStore?.store.fileCollectionsAllLoaded() == true else { return } + guard shouldRefreshTeams(forced: forced), refreshInProgress == false, enableOnlineRegistration, hasEnded() == false else { return } + refreshInProgress = true + do { +// try await self.tournamentStore?.playerRegistrations.loadDataFromServerIfAllowed(clear: true) +// try await self.tournamentStore?.teamScores.loadDataFromServerIfAllowed(clear: true) +// try await self.tournamentStore?.teamRegistrations.loadDataFromServerIfAllowed(clear: true) + refreshInProgress = false + lastTeamRefresh = Date() + } catch { + Logger.error(error) + refreshInProgress = false + lastTeamRefresh = Date() + } + } + + // MARK: - + + func insertOnServer() throws { + + DataStore.shared.tournaments.writeChangeAndInsertOnServer(instance: self) + + if let teamRegistrations = self.tournamentStore?.teamRegistrations { + for teamRegistration in teamRegistrations { + teamRegistration.insertOnServer() + } + } + + if let groupStages = self.tournamentStore?.groupStages { + for groupStage in groupStages { + groupStage.insertOnServer() + } + } + if let rounds = self.tournamentStore?.rounds { + for round in rounds { + round.insertOnServer() + } + } + + } + + + // MARK: - Payments & Crypto + + public enum PaymentError: Error { + case cantPayTournament + } + + // MARK: - Refacto + + + public var tournamentCategory: TournamentCategory { + get { + federalCategory + } + set { + if federalCategory != newValue { + federalCategory = newValue + updateWeights() + } else { + federalCategory = newValue + } + } + } + + func updateWeights() { + let teams = self.unsortedTeams() + teams.forEach { team in + let players = team.unsortedPlayers() + players.forEach { $0.setComputedRank(in: self) } + team.setWeight(from: players, inTournamentCategory: tournamentCategory) + self.tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players) + } + self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: teams) + } + + +} + +extension Bool { + var encodedValue: Int { + switch self { + case true: + return Int.random(in: (0...4)) + case false: + return Int.random(in: (5...9)) + } + } + static func decodeInt(_ int: Int) -> Bool { + switch int { + case (0...4): + return true + default: + return false + } + } +} + +//extension Tournament { +// enum CodingKeys: String, CodingKey { +// case _id = "id" +// case _event = "event" +// case _name = "name" +// case _startDate = "startDate" +// case _endDate = "endDate" +// case _creationDate = "creationDate" +// case _isPrivate = "isPrivate" +// case _groupStageFormat = "groupStageFormat" +// case _roundFormat = "roundFormat" +// case _loserRoundFormat = "loserRoundFormat" +// case _groupStageSortMode = "groupStageSortMode" +// case _groupStageCount = "groupStageCount" +// case _rankSourceDate = "rankSourceDate" +// case _dayDuration = "dayDuration" +// case _teamCount = "teamCount" +// case _teamSorting = "teamSorting" +// case _federalCategory = "federalCategory" +// case _federalLevelCategory = "federalLevelCategory" +// case _federalAgeCategory = "federalAgeCategory" +// case _groupStageCourtCount = "groupStageCourtCount" +// case _closedRegistrationDate = "closedRegistrationDate" +// case _groupStageAdditionalQualified = "groupStageAdditionalQualified" +// case _courtCount = "courtCount" +// case _prioritizeClubMembers = "prioritizeClubMembers" +// case _qualifiedPerGroupStage = "qualifiedPerGroupStage" +// case _teamsPerGroupStage = "teamsPerGroupStage" +// case _entryFee = "entryFee" +// case _additionalEstimationDuration = "additionalEstimationDuration" +// case _isDeleted = "isDeleted" +// case _isCanceled = "localId" +// case _payment = "globalId" +// } +//} + + +/// Warning: if the enum has more than 10 cases, the payment algo is broken +public enum TournamentPayment: Int, CaseIterable { + case free, unit, subscriptionUnit, unlimited + + var isSubscription: Bool { + switch self { + case .subscriptionUnit, .unlimited: + return true + default: + return false + } + } + +} diff --git a/PadelClubData/Data/TournamentLibrary.swift b/PadelClubData/Data/TournamentLibrary.swift new file mode 100644 index 0000000..062083f --- /dev/null +++ b/PadelClubData/Data/TournamentLibrary.swift @@ -0,0 +1,33 @@ +// +// TournamentLibrary.swift +// PadelClub +// +// Created by Laurent Morvillier on 11/11/2024. +// + +import Foundation +import LeStorage + +class TournamentLibrary { + + static let shared: TournamentLibrary = TournamentLibrary() + + fileprivate var _stores: [String : TournamentStore] = [:] + + func store(tournamentId: String) -> TournamentStore? { + guard let tournament = DataStore.shared.tournaments.first(where: { $0.id == tournamentId }) else { return nil } + + if let store = self._stores[tournamentId] { + return store + } + let store = StoreCenter.main.store(identifier: tournamentId) + let tournamentStore = TournamentStore(store: store) + self._stores[tournamentId] = tournamentStore + return tournamentStore + } + + func reset() { + self._stores.removeAll() + } + +} diff --git a/PadelClubData/Data/TournamentStore.swift b/PadelClubData/Data/TournamentStore.swift new file mode 100644 index 0000000..e21830c --- /dev/null +++ b/PadelClubData/Data/TournamentStore.swift @@ -0,0 +1,69 @@ +// +// TournamentStore.swift +// PadelClub +// +// Created by Laurent Morvillier on 26/06/2024. +// + +import Foundation +import LeStorage +import SwiftUI +import Combine + +public class TournamentStore: ObservableObject { + + var store: Store + + public fileprivate(set) var groupStages: SyncedCollection = SyncedCollection.placeholder() + public fileprivate(set) var matches: SyncedCollection = SyncedCollection.placeholder() + public fileprivate(set) var teamRegistrations: SyncedCollection = SyncedCollection.placeholder() + public fileprivate(set) var playerRegistrations: SyncedCollection = SyncedCollection.placeholder() + public fileprivate(set) var rounds: SyncedCollection = SyncedCollection.placeholder() + public fileprivate(set) var teamScores: SyncedCollection = SyncedCollection.placeholder() + + public fileprivate(set) var matchSchedulers: StoredCollection = StoredCollection.placeholder() + public fileprivate(set) var drawLogs: SyncedCollection = SyncedCollection.placeholder() + +// convenience init(tournament: Tournament) { +// let store = StoreCenter.main.store(identifier: tournament.id) +// self.init(store: store) +// self._initialize() +// } + + init(store: Store) { + self.store = store + self._initialize() + } + + fileprivate func _initialize() { + + let indexed: Bool = true + + self.groupStages = self.store.registerSynchronizedCollection(indexed: indexed) + self.rounds = self.store.registerSynchronizedCollection(indexed: indexed) + self.teamRegistrations = self.store.registerSynchronizedCollection(indexed: indexed) + self.playerRegistrations = self.store.registerSynchronizedCollection(indexed: indexed) + self.matches = self.store.registerSynchronizedCollection(indexed: indexed) + self.teamScores = self.store.registerSynchronizedCollection(indexed: indexed) + self.matchSchedulers = self.store.registerCollection(indexed: indexed) + self.drawLogs = self.store.registerSynchronizedCollection(indexed: indexed) + + self.store.loadCollectionsFromServerIfNoFile() + + NotificationCenter.default.addObserver( + self, + selector: #selector(_leStorageDidSynchronize), + name: NSNotification.Name.LeStorageDidSynchronize, + object: nil) + + } + + @objc func _leStorageDidSynchronize(notification: Notification) { +// Logger.log("SYNCED > teamRegistrations count = \(self.teamRegistrations.count)") + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + +} diff --git a/PadelClubData/Extensions/Array+Extensions.swift b/PadelClubData/Extensions/Array+Extensions.swift new file mode 100644 index 0000000..8669783 --- /dev/null +++ b/PadelClubData/Extensions/Array+Extensions.swift @@ -0,0 +1,95 @@ +// +// Array+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 03/03/2024. +// + +import Foundation + +public extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } + + func anySatisfy(_ p: (Element) -> Bool) -> Bool { + return first(where: { p($0) }) != nil + //return !self.allSatisfy { !p($0) } + } + + // Check if the number of elements in the sequence is even + var isEven: Bool { + return self.count % 2 == 0 + } + + // Check if the number of elements in the sequence is odd + var isOdd: Bool { + return self.count % 2 != 0 + } +} + + +extension Array where Element: CustomStringConvertible { + func customJoined(separator: String, lastSeparator: String) -> String { + switch count { + case 0: + return "" + case 1: + return "\(self[0])" + case 2: + return "\(self[0]) \(lastSeparator) \(self[1])" + default: + let firstPart = dropLast().map { "\($0)" }.joined(separator: ", ") + let lastPart = "\(lastSeparator) \(last!)" + return "\(firstPart) \(lastPart)" + } + } +} + +extension Dictionary where Key == Int, Value == [String] { + mutating func setOrAppend(_ element: String?, at key: Int) { + // Check if the element is nil; do nothing if it is + guard let element = element else { + return + } + + // Check if the key exists in the dictionary + if var array = self[key] { + // If it exists, append the element to the array + array.append(element) + self[key] = array + } else { + // If it doesn't exist, create a new array with the element + self[key] = [element] + } + } +} + + +public extension Array where Element == String { + func formatList(maxDisplay: Int = 2) -> [String] { + // Check if the array has fewer or equal elements than the maximum display limit + if self.count <= maxDisplay { + // Join all elements with commas + return self + } else { + // Join only the first `maxDisplay` elements and add "et plus" + let displayedItems = self.prefix(maxDisplay) + let remainingCount = self.count - maxDisplay + return displayedItems.dropLast() + [displayedItems.last! + " et \(remainingCount) de plus"] + } + } +} + +public extension Array { + func sorted(by keyPath: KeyPath, order: SortOrder) -> [Element] { + switch order { + case .ascending: + return self.sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] } + case .descending: + return self.sorted { $0[keyPath: keyPath] > $1[keyPath: keyPath] } + } + } +} diff --git a/PadelClubData/Extensions/Calendar+Extensions.swift b/PadelClubData/Extensions/Calendar+Extensions.swift new file mode 100644 index 0000000..56f7d74 --- /dev/null +++ b/PadelClubData/Extensions/Calendar+Extensions.swift @@ -0,0 +1,71 @@ +// +// Calendar+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 28/03/2024. +// + +import Foundation + +public extension Calendar { + func numberOfDaysBetween(_ from: Date?, and to: Date?) -> Int { + guard let from, let to else { return 0 } + let fromDate = startOfDay(for: from) + let toDate = startOfDay(for: to) + let numberOfDays = dateComponents([.day], from: fromDate, to: toDate) + + return numberOfDays.day! // <1> + } + + func isSameDay(date1: Date?, date2: Date?) -> Bool { + guard let date1, let date2 else { return false } + + return numberOfDaysBetween(date1, and: date2) == 0 + } + + func getSportAge() -> Int { + let currentDate = Date() + + // Get the current year + let currentYear = component(.year, from: currentDate) + + // Define the date components for 1st September and 31st December of the current year + let septemberFirstComponents = DateComponents(year: currentYear, month: 9, day: 1) + let decemberThirtyFirstComponents = DateComponents(year: currentYear, month: 12, day: 31) + + // Get the actual dates for 1st September and 31st December + let septemberFirst = date(from: septemberFirstComponents)! + let decemberThirtyFirst = date(from: decemberThirtyFirstComponents)! + + // Determine the sport year + let sportYear: Int + if currentDate >= septemberFirst && currentDate <= decemberThirtyFirst { + // If after 1st September and before 31st December, use current year + 1 + sportYear = currentYear + 1 + } else { + // Otherwise, use the current year + sportYear = currentYear + } + return sportYear + } +} + +public extension Calendar { + // Add or subtract months from a date + func addMonths(_ months: Int, to date: Date) -> Date { + return self.date(byAdding: .month, value: months, to: date)! + } + + // Generate a list of month start dates between two dates + func generateMonthRange(startDate: Date, endDate: Date) -> [Date] { + var dates: [Date] = [] + var currentDate = startDate + + while currentDate <= endDate { + dates.append(currentDate) + currentDate = self.addMonths(1, to: currentDate) + } + + return dates + } +} diff --git a/PadelClubData/Extensions/CodingContainer+Extensions.swift b/PadelClubData/Extensions/CodingContainer+Extensions.swift new file mode 100644 index 0000000..f0b63e6 --- /dev/null +++ b/PadelClubData/Extensions/CodingContainer+Extensions.swift @@ -0,0 +1,45 @@ +// +// KeyedEncodingContainer+Extensions.swift +// PadelClub +// +// Created by Laurent Morvillier on 18/09/2024. +// + +import Foundation +import LeStorage + +extension KeyedDecodingContainer { + + func decodeEncrypted(key: Key) throws -> String { + let data = try self.decode(Data.self, forKey: key) + return try data.decryptData(pass: CryptoKey.pass.rawValue) + } + + func decodeEncryptedIfPresent(key: Key) throws -> String? { + let data = try self.decodeIfPresent(Data.self, forKey: key) + if let data { + return try data.decryptData(pass: CryptoKey.pass.rawValue) + } + return nil + } + +} + +extension KeyedEncodingContainer { + + mutating func encodeAndEncrypt(_ value: Data, forKey key: Key) throws { + let encryped: Data = try value.encrypt(pass: CryptoKey.pass.rawValue) + try self.encode(encryped, forKey: key) + } + + mutating func encodeAndEncryptIfPresent(_ value: Data?, forKey key: Key) throws { + guard let value else { + try encodeNil(forKey: key) + return + } + try self.encodeAndEncrypt(value, forKey: key) + } + +} + + diff --git a/PadelClubData/Extensions/Date+Extensions.swift b/PadelClubData/Extensions/Date+Extensions.swift new file mode 100644 index 0000000..c991acd --- /dev/null +++ b/PadelClubData/Extensions/Date+Extensions.swift @@ -0,0 +1,262 @@ +// +// Date+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/03/2024. +// + +import Foundation +public enum TimeOfDay { + case morning + case noon + case afternoon + case evening + case night + + public var hello: String { + switch self { + case .morning, .noon, .afternoon: + return "Bonjour" + case .evening, .night: + return "Bonsoir" + } + } + + public var goodbye: String { + switch self { + case .morning, .noon, .afternoon: + return "Bonne journée" + case .evening, .night: + return "Bonne soirée" + } + } + +} + + + +public extension Date { + func withoutSeconds() -> Date { + let calendar = Calendar.current + return calendar.date(bySettingHour: calendar.component(.hour, from: self), + minute: calendar.component(.minute, from: self), + second: 0, + of: self)! + } + + func localizedDate() -> String { + self.formatted(.dateTime.weekday().day().month()) + " à " + self.formattedAsHourMinute() + } + + func formattedAsHourMinute() -> String { + formatted(.dateTime.hour().minute()) + } + + func formattedAsDate() -> String { + formatted(.dateTime.weekday().day(.twoDigits).month().year()) + } + + var monthYearFormatted: String { + formatted(.dateTime.month(.wide).year(.defaultDigits)) + } + + var twoDigitsYearFormatted: String { + formatted(Date.FormatStyle(date: .numeric, time: .omitted).locale(Locale(identifier: "fr_FR")).year(.twoDigits)) + } + + var timeOfDay: TimeOfDay { + let hour = Calendar.current.component(.hour, from: self) + switch hour { + case 6..<12 : return .morning + case 12 : return .noon + case 13..<17 : return .afternoon + case 17..<22 : return .evening + default: return .night + } + } + +} + +public 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..= 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)! + } +} + +public extension Date { + func isEarlierThan(_ date: Date) -> Bool { + Calendar.current.compare(self, to: date, toGranularity: .minute) == .orderedAscending + } +} + +public extension Date { + func localizedTime() -> String { + self.formattedAsHourMinute() + } + + func localizedDay() -> String { + self.formatted(.dateTime.weekday(.wide).day()) + } + + func localizedWeekDay() -> String { + self.formatted(.dateTime.weekday(.wide)) + } + + func timeElapsedString() -> String { + let timeInterval = abs(Date().timeIntervalSince(self)) + let duration = Duration.seconds(timeInterval) + + let formatStyle = Duration.UnitsFormatStyle(allowedUnits: [.hours, .minutes], width: .narrow) + return formatStyle.format(duration) + } + + static var hourMinuteFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.allowedUnits = [.hour, .minute] // Customize units + formatter.unitsStyle = .abbreviated // You can choose .abbreviated or .short + return formatter + }() + + func truncateMinutesAndSeconds() -> Date { + let calendar = Calendar.current + return calendar.date(bySetting: .minute, value: 0, of: self)!.withoutSeconds() + } +} diff --git a/PadelClubData/Extensions/FixedWidthInteger+Extensions.swift b/PadelClubData/Extensions/FixedWidthInteger+Extensions.swift new file mode 100644 index 0000000..37815d3 --- /dev/null +++ b/PadelClubData/Extensions/FixedWidthInteger+Extensions.swift @@ -0,0 +1,43 @@ +// +// FixedWidthInteger+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 03/03/2024. +// + +import Foundation + +public extension FixedWidthInteger { + func ordinalFormattedSuffix(feminine: Bool = false) -> String { + switch self { + case 1: return feminine ? "ère" : "er" + default: return "ème" + } + } + + func ordinalFormatted(feminine: Bool = false) -> String { + return self.formatted() + self.ordinalFormattedSuffix(feminine: feminine) + } + + private var isMany: Bool { + self > 1 || self < -1 + } + + var pluralSuffix: String { + return isMany ? "s" : "" + } + + func localizedPluralSuffix(_ plural: String = "s") -> String { + return isMany ? plural : "" + } + + func formattedAsRawString() -> String { + String(self) + } + + func durationInHourMinutes() -> String { + let duration = Duration.seconds(self*60) + let formatStyle = Duration.UnitsFormatStyle(allowedUnits: [.hours, .minutes], width: .narrow) + return formatStyle.format(duration) + } +} diff --git a/PadelClubData/Extensions/Locale+Extensions.swift b/PadelClubData/Extensions/Locale+Extensions.swift new file mode 100644 index 0000000..cb316b6 --- /dev/null +++ b/PadelClubData/Extensions/Locale+Extensions.swift @@ -0,0 +1,28 @@ +// +// Locale+Extensions.swift +// PadelClub +// +// Created by Laurent Morvillier on 03/04/2024. +// + +import Foundation + +public extension Locale { + + static func countries() -> [String] { + var countries: [String] = [] + + for countryCode in Locale.Region.isoRegions { + if let countryName = Locale.current.localizedString(forRegionCode: countryCode.identifier) { + countries.append(countryName) + } + } + + return countries.sorted() + } + + static func defaultCurrency() -> String { +// return "EUR" + Locale.current.currency?.identifier ?? "EUR" + } +} diff --git a/PadelClubData/Extensions/NumberFormatter+Extensions.swift b/PadelClubData/Extensions/NumberFormatter+Extensions.swift new file mode 100644 index 0000000..1711cf9 --- /dev/null +++ b/PadelClubData/Extensions/NumberFormatter+Extensions.swift @@ -0,0 +1,20 @@ +// +// NumberFormatter+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 27/03/2024. +// + +import Foundation + +public extension NumberFormatter { + static var ordinal: NumberFormatter { + let formatter = NumberFormatter() + formatter.numberStyle = .ordinal + return formatter + } + + static var standard: NumberFormatter { + return NumberFormatter() + } +} diff --git a/PadelClubData/Extensions/Sequence+Extensions.swift b/PadelClubData/Extensions/Sequence+Extensions.swift new file mode 100644 index 0000000..70ef0c1 --- /dev/null +++ b/PadelClubData/Extensions/Sequence+Extensions.swift @@ -0,0 +1,105 @@ +// +// Sequence+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 03/03/2024. +// + +import Foundation + +public 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 + } +} + +public extension Sequence { + func sorted(by keyPath: KeyPath) -> [Element] { + return sorted { a, b in + return a[keyPath: keyPath] < b[keyPath: keyPath] + } + } +} + +public extension Sequence { + func pairs() -> AnySequence<(Element, Element)> { + AnySequence(zip(self, self.dropFirst())) + } +} + +public extension Sequence { + + func concurrentForEach( + _ operation: @escaping (Element) async throws -> Void + ) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + // First, create all tasks + for element in self { + group.addTask { + try await operation(element) + } + } + + // Then wait for all tasks to complete + for try await _ in group {} + } + } + + func concurrentForEach( + _ operation: @escaping (Element) async -> Void + ) async { + await withTaskGroup(of: Void.self) { group in + // First, add all tasks + for element in self { + group.addTask { + await operation(element) + } + } + + // Then wait for all tasks to complete + for await _ in group {} + } + } + +} + +public enum SortOrder { + case ascending + case descending +} + +public extension Sequence { + func sorted(using descriptors: [MySortDescriptor], + 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 + } + } +} + +public extension Sequence { + func sorted(using descriptors: MySortDescriptor...) -> [Element] { + sorted(using: descriptors, order: .ascending) + } +} + diff --git a/PadelClubData/Extensions/String+Crypto.swift b/PadelClubData/Extensions/String+Crypto.swift new file mode 100644 index 0000000..903b588 --- /dev/null +++ b/PadelClubData/Extensions/String+Crypto.swift @@ -0,0 +1,46 @@ +// +// 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) + } + +} diff --git a/PadelClubData/Extensions/String+Extensions.swift b/PadelClubData/Extensions/String+Extensions.swift new file mode 100644 index 0000000..c8ea2a6 --- /dev/null +++ b/PadelClubData/Extensions/String+Extensions.swift @@ -0,0 +1,272 @@ +// +// String+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/03/2024. +// + +import Foundation + +// MARK: - Trimming and stuff + +public extension String { + func trunc(length: Int, trailing: String = "…") -> String { + if length <= 0 { return self } + return (self.count > length) ? self.prefix(length) + trailing : self + } + + func prefixTrimmed(_ length: Int) -> String { + String(trimmed.prefix(length)) + } + + func prefixMultilineTrimmed(_ length: Int) -> String { + String(trimmedMultiline.prefix(length)) + } + + var trimmed: String { + replaceCharactersFromSet(characterSet: .newlines, replacementString: " ").trimmingCharacters(in: .whitespacesAndNewlines) + } + + var trimmedMultiline: String { + self.trimmingCharacters(in: .whitespacesAndNewlines) + } + + func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String { + components(separatedBy: characterSet).joined(separator:replacementString) + } + + var canonicalVersion: String { + trimmed.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ").folding(options: .diacriticInsensitive, locale: .current).lowercased() + } + + var canonicalVersionWithPunctuation: String { + trimmed.folding(options: .diacriticInsensitive, locale: .current).lowercased() + } + + var removingFirstCharacter: String { + String(dropFirst()) + } + + func isValidEmail() -> Bool { + let emailRegEx = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$" + let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx) + return emailPredicate.evaluate(with: self) + } + +} + +// MARK: - Club Name +public extension String { + func acronym() -> String { + let acronym = canonicalVersion.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines) + if acronym.count > 10 { + return concatenateFirstLetters().uppercased() + } else { + return acronym.uppercased() + } + } + + func concatenateFirstLetters() -> String { + // Split the input into sentences + let sentences = self.components(separatedBy: .whitespacesAndNewlines) + if sentences.count == 1 { + return String(self.prefix(10)) + } + // Extract the first character of each sentence + let firstLetters = sentences.compactMap { sentence -> Character? in + let trimmedSentence = sentence.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmedSentence.count > 2 { + if let firstCharacter = trimmedSentence.first { + return firstCharacter + } + } + return nil + } + + // Join the first letters together into a string + let result = String(firstLetters) + return String(result.prefix(10)) + } +} + +// MARK: - FFT License +public 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..= "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 +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}$/ + } + + func isMobileNumber() -> Bool { + firstMatch(of: RegexStatic.mobileNumber) != nil + } + + func isPhoneNumber() -> Bool { + firstMatch(of: RegexStatic.phoneNumber) != nil + } + + func cleanSearchText() -> String { + // Create a character set of all punctuation except slashes and hyphens + var punctuationToRemove = CharacterSet.punctuationCharacters + punctuationToRemove.remove(charactersIn: "/-") + + // Remove the unwanted punctuation + return self.components(separatedBy: punctuationToRemove) + .joined(separator: " ") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + //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 +public extension StringProtocol { + var firstUppercased: String { prefix(1).uppercased() + dropFirst() } + var firstCapitalized: String { prefix(1).capitalized + dropFirst() } +} + +// MARK: - todo clean up ?? +public extension LosslessStringConvertible { + var string: String { .init(self) } +} + +public 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 + } +} + +public extension String { + func toInt() -> Int? { + Int(self) + } +} + +extension String : @retroactive Identifiable { + public var id: String { self } +} + +public extension String { + /// Parses the birthdate string into a `Date` based on multiple formats. + /// - Returns: A `Date` object if parsing is successful, or `nil` if the format is unrecognized. + func parseAsBirthdate() -> Date? { + let dateFormats = [ + "yyyy-MM-dd", // Format for "1993-01-31" + "dd/MM/yyyy", // Format for "27/07/1992" + "dd/MM/yy" // Format for "27/07/92" + ] + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") // Ensure consistent parsing + + for format in dateFormats { + dateFormatter.dateFormat = format + + if let date = dateFormatter.date(from: self) { + return date // Return the parsed date if successful + } + } + + return nil // Return nil if no format matches + } + + /// Formats the birthdate string into "DD/MM/YYYY". + /// - Returns: A formatted birthdate string, or the original string if parsing fails. + public func formattedAsBirthdate() -> String { + if let parsedDate = self.parseAsBirthdate() { + let outputFormatter = DateFormatter() + outputFormatter.dateFormat = "dd/MM/yyyy" // Desired output format + return outputFormatter.string(from: parsedDate) + } + return self // Return the original string if parsing fails + } +} diff --git a/PadelClubData/Extensions/Tournament+Extensions.swift b/PadelClubData/Extensions/Tournament+Extensions.swift new file mode 100644 index 0000000..907bc6a --- /dev/null +++ b/PadelClubData/Extensions/Tournament+Extensions.swift @@ -0,0 +1,16 @@ +// +// Tournament+Extensions.swift +// PadelClubData +// +// Created by Laurent Morvillier on 17/04/2025. +// + +import Foundation + +extension Tournament { + + public static func fake() -> Tournament { + return Tournament(event: "Roland Garros", name: "Magic P100", startDate: Date(), endDate: Date(), creationDate: Date(), isPrivate: false, groupStageFormat: .nineGames, roundFormat: nil, loserRoundFormat: nil, groupStageSortMode: .snake, groupStageCount: 4, rankSourceDate: nil, dayDuration: 2, teamCount: 24, teamSorting: .rank, federalCategory: .men, federalLevelCategory: .p100, federalAgeCategory: .a45, closedRegistrationDate: nil, groupStageAdditionalQualified: 0, courtCount: 4, prioritizeClubMembers: false, qualifiedPerGroupStage: 2, teamsPerGroupStage: 4, entryFee: nil) + } + +} diff --git a/PadelClubData/Extensions/URL+Extensions.swift b/PadelClubData/Extensions/URL+Extensions.swift new file mode 100644 index 0000000..6afb0c2 --- /dev/null +++ b/PadelClubData/Extensions/URL+Extensions.swift @@ -0,0 +1,182 @@ +// +// URL+Extensions.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/03/2024. +// + +import Foundation + +public extension URL { + + public static var savedDateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "DD/MM/yyyy" + return df + }() + + public 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) + } +} + +public 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 + } + + public 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 + } + + public func fileModelIdentifier() -> String? { + // Read the contents of the file + guard let fileContents = try? String(contentsOfFile: path(), encoding: .utf8) else { + return nil + } + + // Split the contents by newline characters + let lines = fileContents.components(separatedBy: .newlines) + + if let line = lines.first(where: { + $0.hasPrefix("file-model-version:") + }) { + return line.replacingOccurrences(of: "file-model-version:", with: "") + } + + return nil + } + + public 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 + } +} diff --git a/PadelClubData/PadelClubData.docc/PadelClubData.md b/PadelClubData/PadelClubData.docc/PadelClubData.md new file mode 100644 index 0000000..23dbee6 --- /dev/null +++ b/PadelClubData/PadelClubData.docc/PadelClubData.md @@ -0,0 +1,13 @@ +# ``PadelClubData`` + +Summary + +## Overview + +Text + +## Topics + +### Group + +- ``Symbol`` \ No newline at end of file diff --git a/PadelClubData/PadelClubData.h b/PadelClubData/PadelClubData.h new file mode 100644 index 0000000..f944b97 --- /dev/null +++ b/PadelClubData/PadelClubData.h @@ -0,0 +1,18 @@ +// +// PadelClubData.h +// PadelClubData +// +// Created by Laurent Morvillier on 15/04/2025. +// + +#import + +//! Project version number for PadelClubData. +FOUNDATION_EXPORT double PadelClubDataVersionNumber; + +//! Project version string for PadelClubData. +FOUNDATION_EXPORT const unsigned char PadelClubDataVersionString[]; + +// In this header, you should import all the public headers of your framework using statements like #import + + diff --git a/PadelClubData/Patcher.swift b/PadelClubData/Patcher.swift new file mode 100644 index 0000000..db2efe8 --- /dev/null +++ b/PadelClubData/Patcher.swift @@ -0,0 +1,143 @@ +// +// Patcher.swift +// PadelClub +// +// Created by Laurent Morvillier on 21/06/2024. +// + +import Foundation +import LeStorage + +public enum ManualPatch: String { + case disconnect + + var id: String { + return "padelclub.app.manual.patch.\(self.rawValue)" + } +} + +public class ManualPatcher { + + public static func patchIfPossible(_ patch: ManualPatch) -> Bool { + if UserDefaults.standard.value(forKey: patch.id) == nil { + do { + Logger.log(">>> Patches \(patch.rawValue)...") + let result = try self._applyPatch(patch) + UserDefaults.standard.setValue(true, forKey: patch.id) + return result + } catch { + Logger.error(error) + } + } + return false + } + + fileprivate static func _applyPatch(_ patch: ManualPatch) throws -> Bool { + switch patch { + case .disconnect: + let rawToken = try? StoreCenter.main.rawTokenShouldNotBeUsed() + if StoreCenter.main.userName != nil || StoreCenter.main.userId != nil || rawToken != nil { + DataStore.shared.disconnect() + return true + } else { + return false + } + } + } +} + +enum PatchError: Error { + case patchError(message: String) +} + +enum Patch: String, CaseIterable { + case cleanLogs + case syncUpgrade + case updateTournaments + case cleanPurchaseApiCalls = "cleanPurchaseApiCalls_2" + + var id: String { + return "padelclub.app.patch.\(self.rawValue)" + } +} + +public class AutomaticPatcher { + + 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 .cleanLogs: self._cleanLogs() + case .syncUpgrade: self._syncUpgrade() + case .updateTournaments: self._updateTournaments() + case .cleanPurchaseApiCalls: self._cleanPurchaseApiCalls() + } + } + + fileprivate static func _cleanLogs() { + StoreCenter.main.resetLoggingCollections() + } + + fileprivate static func _syncUpgrade() { + for tournament in DataStore.shared.tournaments { + let id = tournament.id + + guard let store = TournamentLibrary.shared.store(tournamentId: tournament.id) else { continue } + + for round in store.rounds { + round.storeId = id + } + store.rounds.addOrUpdate(contentOfs: store.rounds) + for groupStage in store.groupStages { + groupStage.storeId = id + } + store.groupStages.addOrUpdate(contentOfs: store.groupStages) + for teamRegistration in store.teamRegistrations { + teamRegistration.storeId = id + } + store.teamRegistrations.addOrUpdate(contentOfs: store.teamRegistrations) + for pr in store.playerRegistrations { + pr.storeId = id + } + store.playerRegistrations.addOrUpdate(contentOfs: store.playerRegistrations) + for match in store.matches { + match.storeId = id + } + store.matches.addOrUpdate(contentOfs: store.matches) + for ts in store.teamScores { + ts.storeId = id + } + store.teamScores.addOrUpdate(contentOfs: store.teamScores) + for ms in store.matchSchedulers { + ms.storeId = id + } + store.matchSchedulers.addOrUpdate(contentOfs: store.matchSchedulers) + + } + } + + fileprivate static func _updateTournaments() { + DataStore.shared.tournaments.addOrUpdate(contentOfs: DataStore.shared.tournaments) + } + + fileprivate static func _cleanPurchaseApiCalls() { + StoreCenter.main.resetApiCalls(type: Purchase.self) + } + +} diff --git a/PadelClubData/Selectable.swift b/PadelClubData/Selectable.swift new file mode 100644 index 0000000..19d1158 --- /dev/null +++ b/PadelClubData/Selectable.swift @@ -0,0 +1,79 @@ +// +// Selectable.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/04/2024. +// + +import Foundation +import SwiftUI +import TipKit + +protocol Selectable { + func selectionLabel(index: Int) -> String + func badgeValue() -> Int? + func badgeImage() -> Badge? + func badgeValueColor() -> Color? + func displayImageIfValueZero() -> Bool + func systemImage() -> String? + func associatedTip() -> (any Tip)? +} + +extension Selectable { + func associatedTip() -> (any Tip)? { + return nil + } + + func systemImage() -> String? { + return nil + } + + func displayImageIfValueZero() -> Bool { + return false + } +} + +public enum Badge { + case checkmark + case xmark + case custom(systemName: String, color: Color) + + public func systemName() -> String { + switch self { + case .checkmark: + return "checkmark.circle.fill" + case .xmark: + return "xmark.circle.fill" + case .custom(let systemName, _): + return systemName + } + } + +} + +struct SelectionTipViewModifier: ViewModifier { + let selectable: Selectable + let action: () -> Void + func body(content: Content) -> some View { + if let tip = selectable.associatedTip() { + if #available(iOS 18.0, *) { + content + .popoverTip(tip, arrowEdge: .top) { _ in + action() + tip.invalidate(reason: .tipClosed) + } + } else { + content + } + } else { + content + } + } +} + +extension View { + func selectableTipViewModifier(selectable: Selectable, action: @escaping () -> Void) -> some View { + modifier(SelectionTipViewModifier(selectable: selectable, action: action)) + } +} + diff --git a/PadelClubData/SortOption.swift b/PadelClubData/SortOption.swift new file mode 100644 index 0000000..dc145e7 --- /dev/null +++ b/PadelClubData/SortOption.swift @@ -0,0 +1,74 @@ +// +// SortOption.swift +// PadelClubData +// +// Created by Laurent Morvillier on 15/04/2025. +// + +import Foundation + +public enum SortOption: Int, CaseIterable, Identifiable { + case name + case rank + case tournamentCount + case points + case progression + + public var id: Int { self.rawValue } + + public func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { + switch self { + case .name: + return "Nom" + case .rank: + return "Rang" + case .tournamentCount: + return "Tournoi" + case .points: + return "Points" + case .progression: + return "Progression" + } + } + +// func sortDescriptors(_ ascending: Bool, dataSet: DataSet) -> [SortDescriptor] { +// switch self { +// case .name: +// return [ +// SortDescriptor(\ImportedPlayer.lastName, order: ascending ? .forward : .reverse), +// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), +// ] +// case .rank: +// if dataSet == .national || dataSet == .ligue { +// return [ +// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse) +// ] +// } else { +// return [ +// SortDescriptor(\ImportedPlayer.rank, order: ascending ? .forward : .reverse), +// SortDescriptor(\ImportedPlayer.assimilation), +// SortDescriptor(\ImportedPlayer.lastName), +// ] +// } +// case .tournamentCount: +// return [ +// SortDescriptor( +// \ImportedPlayer.tournamentCount, order: ascending ? .forward : .reverse), +// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), +// SortDescriptor(\ImportedPlayer.lastName), +// ] +// case .points: +// return [ +// SortDescriptor(\ImportedPlayer.points, order: ascending ? .forward : .reverse), +// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), +// SortDescriptor(\ImportedPlayer.lastName), +// ] +// case .progression: +// return [ +// SortDescriptor(\ImportedPlayer.progression, order: ascending ? .forward : .reverse), +// SortDescriptor(\ImportedPlayer.rank), SortDescriptor(\ImportedPlayer.assimilation), +// SortDescriptor(\ImportedPlayer.lastName), +// ] +// } +// } +} diff --git a/PadelClubData/SourceFileManager.swift b/PadelClubData/SourceFileManager.swift new file mode 100644 index 0000000..227629e --- /dev/null +++ b/PadelClubData/SourceFileManager.swift @@ -0,0 +1,250 @@ +// +// SourceFileManager.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/03/2024. +// + +import Foundation +import LeStorage + +public class SourceFileManager { + public static let shared = SourceFileManager() + + init() { + createDirectoryIfNeeded(directoryURL: rankingSourceDirectory) +#if targetEnvironment(simulator) + createDirectoryIfNeeded(directoryURL: anonymousSourceDirectory) +#endif + } + + public let rankingSourceDirectory : URL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "rankings") + public let anonymousSourceDirectory : URL = try! FileManager.default.url(for: .documentDirectory, in: .userDomainMask, appropriateFor: nil, create: true).appending(path: "anonymous") + + func createDirectoryIfNeeded(directoryURL: URL) { + let fileManager = FileManager.default + do { + // 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 + } + + public func lastDataSourceDate() -> Date? { + guard let lastDataSource else { return nil } + return URL.importDateFormatter.date(from: lastDataSource) + } + + public 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) + } + } + + 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 + public 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 + } + + public 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 + } + + public 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 + } + + public var mostRecentDateAvailable: Date? { + allFiles(false).first?.dateFromPath + } + + public 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) + } + } + + public func anonymousFiles() -> [URL] { + let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: anonymousSourceDirectory, includingPropertiesForKeys: nil).filter({ url in + url.pathExtension == "csv" + }) + return allJSONFiles + } + + public func jsonFiles() -> [URL] { + let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in + url.pathExtension == "json" + }) + return allJSONFiles + } + + public 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 } + } + + public func allFiles(_ isManPlayer: Bool) -> [URL] { + allFiles.filter({ url in + url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue) + }) + } + + public 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) + } + + public static func getSortOption() -> [SortOption] { + return SortOption.allCases + } +} + +public enum SourceFile: String, CaseIterable { + case dames = "DAMES" + case messieurs = "MESSIEURS" + + public 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)} + } + + public 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 }) + } + + public var isMan: Bool { + switch self { + case .dames: + return false + default: + return true + } + } +} diff --git a/PadelClubData/Subscriptions/Guard.swift b/PadelClubData/Subscriptions/Guard.swift new file mode 100644 index 0000000..1e13d4b --- /dev/null +++ b/PadelClubData/Subscriptions/Guard.swift @@ -0,0 +1,341 @@ +// +// Guard.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 20/04/2022. +// + +import Foundation +import StoreKit +import LeStorage + +@available(iOS 15, *) +@objc public class Guard: NSObject { + + public static var main: Guard = Guard() + + @Published public private(set) var purchasedTransactions = Set() + + public var currentBestPurchase: Purchase? = nil + + var updateListenerTask: Task? = nil + + fileprivate let _freeTournaments: Int = 3 + + override init() { + + super.init() + + self.updateListenerTask = self.listenForTransactions() + + Task { + do { + try await self.refreshPurchasedAppleProducts() + } catch { + Logger.error(error) + } + } + + NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil) + + } + + deinit { + self.updateListenerTask?.cancel() + NotificationCenter.default.removeObserver(self) + } + + @objc func collectionDidLoad(notification: Notification) { + if let _ = notification.object as? StoredCollection { + self._updateBestPlan() + } + } + + func productIds() async -> [String] { + var productIds: [String] = [] + for await result in Transaction.all { + do { + let verified = try self.checkVerified(result) + productIds.append(verified.productID) + } catch { + Logger.error(error) + } + } + return productIds + } + + public func refreshPurchasedAppleProducts() async throws { + + // Iterate through the user's purchased products. + for await verificationResult in Transaction.currentEntitlements { + let transaction = try await self.processTransactionResult(verificationResult) + print("processs product id = \(transaction.productID)") + DispatchQueue.main.async { + NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil) + } + await transaction.finish() + } + } + + func listenForTransactions() -> Task { + return Task(priority: .background) { + //Iterate through any transactions which didn't come from a direct call to `purchase()`. + for await result in Transaction.updates { +// Logger.log(">>> update = \(result)") + do { + let transaction = try self.checkVerified(result) + + //Deliver content to the user. + await self.updatePurchasedIdentifiers(transaction) + + //Always finish a transaction. + await transaction.finish() + } catch { + Logger.error(error) + //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + + func checkVerified(_ result: VerificationResult) throws -> T { + //Check if the transaction passes StoreKit verification. + switch result { + case .unverified: + //StoreKit has parsed the JWS but failed verification. Don't deliver content to the user. + throw StoreManagerError.failedVerification + case .verified(let safe): + //If the transaction is verified, unwrap and return it. + return safe + } + } + + @MainActor + func updatePurchasedIdentifiers(_ transaction: StoreKit.Transaction) async { + +// Logger.log("\(transaction.productID) > purchase = \(transaction.originalPurchaseDate), exp date= \(transaction.expirationDate), rev date = \(transaction.revocationDate)") + +// Logger.log("purchase date = \(transaction.purchaseDate)") + + do { + if transaction.revocationDate == nil { + // If the App Store has not revoked the transaction, add it to the list of `purchasedIdentifiers`. + purchasedTransactions.insert(transaction) + +// try self._addPurchaseIfPossible(transaction: transaction) + } else { + // If the App Store has revoked this transaction, remove it from the list of `purchasedIdentifiers`. + purchasedTransactions.remove(transaction) +// try self._updatePurchaseIfPossible(transaction: transaction) + } + + try self._updatePurchase(transaction: transaction) + + } catch { + Logger.error(error) + } + + self._updateBestPlan() + } + + fileprivate func _updatePurchase(transaction: StoreKit.Transaction) throws { + let purchases = DataStore.shared.purchases + if let purchase = self._purchaseByTransactionId(transaction.originalID) { + purchase.revocationDate = transaction.revocationDate + purchase.expirationDate = transaction.expirationDate + purchase.purchaseDate = transaction.purchaseDate + purchase.productId = transaction.productID + try purchases.addOrUpdate(instance: purchase) + } else { + let purchase: Purchase = try transaction.purchase() + try purchases.addOrUpdate(instance: purchase) + } + } + +// fileprivate func _addPurchaseIfPossible(transaction: StoreKit.Transaction) throws { +// +// let purchases = DataStore.shared.purchases +// +// if self._purchaseByTransactionId(transaction.originalID) == nil { +// let purchase: Purchase = try transaction.purchase() +// try purchases.addOrUpdate(instance: purchase) +// } +// } +// +// fileprivate func _updatePurchaseIfPossible(transaction: StoreKit.Transaction) throws { +// let purchases = DataStore.shared.purchases +// if let existing: Purchase = self._purchaseByTransactionId(transaction.originalID) { +// existing.revocationDate = transaction.revocationDate +// try purchases.addOrUpdate(instance: existing) +// } +// } + + fileprivate func _purchaseByTransactionId(_ transactionId: UInt64) -> Purchase? { + let purchases = DataStore.shared.purchases + return purchases.first(where: { $0.id == transactionId }) + } + + func processTransactionResult(_ result: VerificationResult) async throws -> StoreKit.Transaction { + + let transaction = try checkVerified(result) + + // Deliver content to the user. + await updatePurchasedIdentifiers(transaction) + + return transaction + } + + public var currentPlan: StoreItem? { + #if DEBUG + if let plan = PListReader.readString(plist: "local", key: "plan"), !plan.isEmpty { + return StoreItem(rawValue: plan) + } + #elseif TESTFLIGHT + return .monthlyUnlimited + #elseif PRODTEST + return .monthlyUnlimited + #else + if let currentBestPurchase = self.currentBestPurchase, let plan = StoreItem(rawValue: currentBestPurchase.productId) { + return plan + } + #endif + return nil + } + + public func userFilteredPurchases() -> [StoreKit.Transaction] { +// Logger.log("self.purchasedTransactions = \(self.purchasedTransactions.count)") + guard let userId = StoreCenter.main.userId, let currentUserUUID: UUID = UUID(uuidString: userId) else { + return [] + } + + let userTransactions = self.purchasedTransactions.filter { currentUserUUID == $0.appAccountToken || $0.appAccountToken == nil } + + let now: Date = Date() +// print("now = \(now)") + return userTransactions.filter { transaction in + if let expirationDate = transaction.expirationDate { +// print("exp = \(expirationDate)") + return expirationDate > now + } else { + return true + } + } + + } + + /// Update best plan by filtering Apple purchases with registered purchases by the user + fileprivate func _updateBestPlan() { + + var purchases: [Purchase] = [] + + // Make sure the purchase has been done with the logged user + let userPurchases = self.userFilteredPurchases().compactMap { try? $0.purchase() } + purchases.append(contentsOf: userPurchases) + + let validPurchases = DataStore.shared.purchases.filter { $0.isValid() } + Logger.log("valid purchases = \(validPurchases.count)") + purchases.append(contentsOf: validPurchases) + + if let purchase = purchases.first(where: { $0.productId == StoreItem.monthlyUnlimited.rawValue }) { + self.currentBestPurchase = purchase + } else if let purchase = purchases.first(where: { $0.productId == StoreItem.fivePerMonth.rawValue }) { + self.currentBestPurchase = purchase + } + + } + + fileprivate func _purchasedTournamentCount() -> Int { + + let purchases = DataStore.shared.purchases + + let units = purchases.filter { $0.productId == StoreItem.unit.rawValue } + return units.reduce(0) { $0 + ($1.quantity ?? 0) } + +// let units = self.userFilteredPurchases().filter { $0.productID == StoreItem.unit.rawValue } +// return units.reduce(0) { $0 + $1.purchasedQuantity } + } + + public func paymentForNewTournament() -> TournamentPayment? { + + switch self.currentPlan { + case .monthlyUnlimited: + return TournamentPayment.unlimited + case .fivePerMonth: + if let purchaseDate = self.currentBestPurchase?.purchaseDate { + let tournaments = DataStore.shared.tournaments.filter { $0.creationDate > purchaseDate && $0.payment == .subscriptionUnit && $0.isCanceled == false } + if tournaments.count < StoreItem.five { + return TournamentPayment.subscriptionUnit + } + } + return self._paymentWithoutSubscription() + default: + return self._paymentWithoutSubscription() + } + + } + + fileprivate func _paymentWithoutSubscription() -> TournamentPayment? { + let freelyPayed: Int = DataStore.shared.tournaments.filter { $0.payment == .free && $0.isCanceled == false }.count + if freelyPayed < self._freeTournaments { + return TournamentPayment.free + } + let tournamentCreditCount: Int = self._purchasedTournamentCount() + let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == .unit && $0.isCanceled == false }.count + if tournamentCreditCount > unitlyPayed { + return TournamentPayment.unit + } + return nil + } + + public var remainingTournaments: Int { + let unitlyPayed = DataStore.shared.tournaments.filter { $0.payment == TournamentPayment.unit }.count + let tournamentCreditCount = self._purchasedTournamentCount() +// let notDeletedTournamentCount = DataStore.shared.tournaments.filter { $0.isDeleted == false }.count + +// Logger.log("unitlyPayed = \(unitlyPayed), purchased = \(tournamentCreditCount) ") + return tournamentCreditCount - unitlyPayed + } + + func disconnect() { + let purchases = DataStore.shared.purchases + purchases.reset() + } + +} + +public struct PurchaseRow: Identifiable { + + public var id: UInt64 + + public var name: String + public var item: StoreItem + public var quantity: Int? + + public init(id: UInt64, name: String, item: StoreItem, quantity: Int? = nil) { + self.id = id + self.name = name + self.item = item + self.quantity = quantity + } + +} + +fileprivate extension StoreKit.Transaction { + + func purchase() throws -> Purchase { + guard let userId = StoreCenter.main.userId else { + throw StoreError.missingUserId + } + + return Purchase(transactionId: self.originalID, + user: userId, + purchaseDate: self.purchaseDate, + productId: self.productID, + quantity: self.purchasedQuantity, + revocationDate: self.revocationDate, + expirationDate: self.expirationDate) + + } + +} diff --git a/PadelClubData/Subscriptions/StoreItem.swift b/PadelClubData/Subscriptions/StoreItem.swift new file mode 100644 index 0000000..233a721 --- /dev/null +++ b/PadelClubData/Subscriptions/StoreItem.swift @@ -0,0 +1,38 @@ +// +// StoreItem.swift +// Padel Club +// +// Created by Laurent Morvillier on 22/04/2024. +// + +import Foundation + +public enum StoreItem: String, Identifiable, CaseIterable { + case monthlyUnlimited = "app.padelclub.tournament.subscription.unlimited" + case fivePerMonth = "app.padelclub.tournament.subscription.five.per.month" + case unit = "app.padelclub.tournament.unit" + + #if DEBUG + static let five: Int = 2 + #else + static let five: Int = 5 + #endif + + public var id: String { return self.rawValue } + + public var systemImage: String { + switch self { + case .monthlyUnlimited: return "infinity.circle.fill" + case .fivePerMonth: return "star.circle.fill" + case .unit: return "tennisball.circle.fill" + } + } + + public var isConsumable: Bool { + switch self { + case .monthlyUnlimited, .fivePerMonth: return false + case .unit: return true + } + } + +} diff --git a/PadelClubData/Subscriptions/StoreManager.swift b/PadelClubData/Subscriptions/StoreManager.swift new file mode 100644 index 0000000..4bf853b --- /dev/null +++ b/PadelClubData/Subscriptions/StoreManager.swift @@ -0,0 +1,140 @@ +// +// Store.swift +// Poker Analytics 6 +// +// Created by Laurent Morvillier on 20/04/2022. +// + +import Foundation +import StoreKit +import LeStorage + +public enum StoreManagerError: Error { + case failedVerification + case missingPlan +} + +public protocol StoreDelegate { + func productsReceived(products: [Product]) + func errorDidOccur(error: Error) +} + +public extension Notification.Name { + static let StoreEventHappened = Notification.Name("storePurchaseSucceeded") +} + +public class StoreManager { + + @Published private(set) var purchasedTransactions = Set() + + var delegate: StoreDelegate? = nil + + var updateListenerTask: Task? = nil + + public init(delegate: StoreDelegate?) { + + self.delegate = delegate + self.updateListenerTask = listenForTransactions() + + Task { + //Initialize the store by starting a product request. + await self.requestProducts() + } + } + + deinit { + self.updateListenerTask?.cancel() + } + + @MainActor + public func requestProducts() async { + do { +// let identifiers: [String] = StoreItem.allCases.map { $0.rawValue } + + var products: [Product] = try await Product.products(for: self._productIdentifiers()) + products = products.sorted { p1, p2 in + return p2.price > p1.price + } + + Logger.log("products = \(products.count)") + self.delegate?.productsReceived(products: products) + } catch { + self.delegate?.errorDidOccur(error: error) + Logger.error(error) + } + } + + fileprivate func _productIdentifiers() -> [String] { + var items: [StoreItem] = [] + switch Guard.main.currentPlan { + case .fivePerMonth: + items = [StoreItem.unit, StoreItem.monthlyUnlimited] + case .monthlyUnlimited: + break + default: + items = StoreItem.allCases + } + return items.map { $0.rawValue } + } + + func listenForTransactions() -> Task { + return Task.detached { + //Iterate through any transactions which didn't come from a direct call to `purchase()`. + for await result in Transaction.updates { + do { + + let transaction = try await Guard.main.processTransactionResult(result) + + //Always finish a transaction. + await transaction.finish() + } catch { + self.delegate?.errorDidOccur(error: error) + + //StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user. + print("Transaction failed verification") + } + } + } + } + + public func purchase(_ product: Product, quantity: Int? = nil) async throws -> StoreKit.Transaction? { + Logger.log("Store purchase started...") + + guard let userId = StoreCenter.main.userId, let uuid: UUID = UUID(uuidString: userId) else { + throw StoreError.missingUserId + } + + var options: Set = [] + let tokenOption = Product.PurchaseOption.appAccountToken(uuid) + options.insert(tokenOption) + + if let quantity = quantity { + let quantityOption = Product.PurchaseOption.quantity(quantity) + options.insert(quantityOption) + } + + let result = try await product.purchase(options: options) + + Logger.log("Store purchase ended with result: \(result)") + + switch result { + case .success(let verificationResult): + + let transaction = try await Guard.main.processTransactionResult(verificationResult) + + // Always finish a transaction. + await transaction.finish() + + DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 100000), execute: { + NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil) + }) + + return transaction + case .userCancelled, .pending: + return nil + default: + return nil + } + } + +} diff --git a/PadelClubData/URLs.swift b/PadelClubData/URLs.swift new file mode 100644 index 0000000..e4c1185 --- /dev/null +++ b/PadelClubData/URLs.swift @@ -0,0 +1,95 @@ +// +// URLs.swift +// PadelClub +// +// Created by Laurent Morvillier on 22/04/2024. +// + +import Foundation + +public enum URLs: String, Identifiable { +// case httpScheme = "https://" +#if DEBUG + case activationHost = "xlr.alwaysdata.net" + case main = "https://xlr.alwaysdata.net/" +// case api = "https://xlr.alwaysdata.net/roads/" +#elseif TESTFLIGHT + case activationHost = "xlr.alwaysdata.net" + case main = "https://xlr.alwaysdata.net/" +// case api = "https://xlr.alwaysdata.net/roads/" +#elseif PRODTEST + case activationHost = "padelclub.app" + case main = "https://padelclub.app/" +// case api = "https://padelclub.app/roads/" +#else + case activationHost = "padelclub.app" + case main = "https://padelclub.app/" +// case api = "https://padelclub.app/roads/" +#endif + + case subscriptions = "https://apple.co/2Th4vqI" + case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/" + //case padelClub = "https://padelclub.app" + case tenup = "https://tenup.fft.fr" + case padelCompetitionGeneralGuide = "https://padelclub.app/static/rules/padel-guide-general.pdf" + case padelCompetitionSpecificGuide = "https://padelclub.app/static/rules/padel-guide-cdc.pdf" + case padelCompetitionRankingGuide = "https://padelclub.app/static/rules/padel-guide-rankings.pdf" + case padelRules = "https://padelclub.app/static/rules/padel-rules.pdf" + case restingDischarge = "https://club.fft.fr/tennisfirmidecazeville/60120370_d/data_1/pdf/fo/formlairededechargederesponsabilitetournoidepadel.pdf" + case appReview = "https://apps.apple.com/app/padel-club/id6484163558?mt=8&action=write-review" + case appDescription = "https://padelclub.app/download/" + case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn" + case appStore = "https://apps.apple.com/app/padel-club/id6484163558" + case eula = "https://www.apple.com/legal/internet-services/itunes/dev/stdeula/" + case privacy = "https://padelclub.app/terms-of-use" + + public var id: String { return self.rawValue } + + public var url: URL { + return URL(string: self.rawValue)! + } + + public func extend(path: String) -> URL { + return URL(string: self.rawValue + path)! + } + +} + +public enum PageLink: String, Identifiable, CaseIterable { + case info = "Informations" + 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)" + + public var id: String { self.rawValue } + + public func localizedLabel() -> String { + rawValue + } + + var path: String { + switch self { + case .matches: + return "" + case .info: + return "info" + case .teams: + return "teams" + case .summons: + return "summons" + case .rankings: + return "rankings" + case .groupStages: + return "group-stages" + case .broadcast: + return "broadcast" + case .clubBroadcast: + return "" + } + } +} + diff --git a/PadelClubData/Utils/CryptoKey.swift b/PadelClubData/Utils/CryptoKey.swift new file mode 100644 index 0000000..324ada1 --- /dev/null +++ b/PadelClubData/Utils/CryptoKey.swift @@ -0,0 +1,12 @@ +// +// Key.swift +// PadelClub +// +// Created by Laurent Morvillier on 30/04/2024. +// + +import Foundation + +enum CryptoKey: String { + case pass = "Aa9QDV1G5MP9ijF2FTFasibNbS/Zun4qXrubIL2P+Ik=" +} diff --git a/PadelClubData/Utils/MySortDescriptor.swift b/PadelClubData/Utils/MySortDescriptor.swift new file mode 100644 index 0000000..541e4e5 --- /dev/null +++ b/PadelClubData/Utils/MySortDescriptor.swift @@ -0,0 +1,27 @@ +// +// MySortDescriptor.swift +// PadelClub +// +// Created by Razmig Sarkissian on 26/03/2024. +// + +import Foundation + +public struct MySortDescriptor { + var comparator: (Value, Value) -> ComparisonResult +} + +public extension MySortDescriptor { + static func keyPath(_ keyPath: KeyPath) -> 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 + } + } +} diff --git a/PadelClubData/Utils/NetworkManager.swift b/PadelClubData/Utils/NetworkManager.swift new file mode 100644 index 0000000..0f32b96 --- /dev/null +++ b/PadelClubData/Utils/NetworkManager.swift @@ -0,0 +1,102 @@ +// +// NetworkManager.swift +// PadelClub +// +// Created by Razmig Sarkissian on 01/03/2024. +// + +import Foundation + +class NetworkManager { + static let shared: NetworkManager = NetworkManager() + + func removeRankingData(lastDateString: String, fileName: String) { + let dateString = ["CLASSEMENT-PADEL", fileName, lastDateString].joined(separator: "-") + ".csv" + + let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! + let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") + try? FileManager.default.removeItem(at: destinationFileUrl) + } + +// func headerDataRankingData(lastDateString: String, fileName: String) async throws { +// let dateString = ["CLASSEMENT-PADEL", fileName, lastDateString].joined(separator: "-") + ".csv" +// +// let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory +// let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") +// let fileURL = URL(string: "https://xlr.alwaysdata.net/static/rankings/\(dateString)") +// +// var request = URLRequest(url:fileURL!) +// request.httpMethod = "HEAD" +// request.addValue("attachment;filename=\(dateString)", forHTTPHeaderField:"Content-Disposition") +// request.addValue("text/csv", forHTTPHeaderField: "Content-Type") +// let task = try await URLSession.shared.dataTask(with: request) +// if let urlResponse = task.1 as? HTTPURLResponse { +// print(urlResponse.allHeaderFields) +// } +// } +// + func formatDateForHTTPHeader(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss 'GMT'" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(secondsFromGMT: 0) // GMT timezone + + return formatter.string(from: date) + } + + @discardableResult + func downloadRankingData(lastDateString: String, fileName: String) async throws -> Int? { + + let dateString = ["CLASSEMENT-PADEL", fileName, lastDateString].joined(separator: "-") + ".csv" + + let documentsUrl: URL = SourceFileManager.shared.rankingSourceDirectory + let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") + let fileURL = URLs.main.extend(path: "static/rankings/\(dateString)") + + 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") + } + request.addValue("text/csv", forHTTPHeaderField: "Content-Type") + + let task = try await URLSession.shared.download(for: request) + if let urlResponse = task.1 as? HTTPURLResponse { + print(dateString, urlResponse.statusCode) + if urlResponse.statusCode == 200 { + + //todo à voir si on en a besoin, permet de re-télécharger un csv si on détecte qu'il a été mis à jour +// if FileManager.default.fileExists(atPath: destinationFileUrl.path()) { +// if let creationDate = try checkFileCreationDate(filePath: task.0.path()), let previousCreationDate = try checkFileCreationDate(filePath: destinationFileUrl.path()) { +// print("File creation date:", creationDate) +// print("File previous creation date:", previousCreationDate) +// if previousCreationDate.isEarlierThan(creationDate) { +// try FileManager.default.removeItem(at: destinationFileUrl) +// } +// } +// } + try? FileManager.default.removeItem(at: destinationFileUrl) + try FileManager.default.copyItem(at: task.0, to: destinationFileUrl) + print("dl rank data ok", lastDateString, fileName) + return destinationFileUrl.fftImportingStatus() ?? 1 + } else if urlResponse.statusCode == 404 && fileName == "MESSIEURS" { + print("dl rank data failedm fileNotYetAvailable", lastDateString, fileName) + throw NetworkManagerError.fileNotYetAvailable + } else if urlResponse.statusCode == 304 { + print("dl rank data failed, fileNotModified", lastDateString, fileName) + throw NetworkManagerError.fileNotModified + } else { + print("dl rank data failed, fileNotDownloaded", lastDateString, fileName, urlResponse.statusCode) + throw NetworkManagerError.fileNotDownloaded(urlResponse.statusCode) + } + } + return nil + } + + func checkFileCreationDate(filePath: String) throws -> Date? { + let fileManager = FileManager.default + let attributes = try fileManager.attributesOfItem(atPath: filePath) + return attributes[.creationDate] as? Date + } + +} diff --git a/PadelClubData/Utils/NetworkManagerError.swift b/PadelClubData/Utils/NetworkManagerError.swift new file mode 100644 index 0000000..970af60 --- /dev/null +++ b/PadelClubData/Utils/NetworkManagerError.swift @@ -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) + } + } +} diff --git a/PadelClubData/Utils/PListReader.swift b/PadelClubData/Utils/PListReader.swift new file mode 100644 index 0000000..1e5af5a --- /dev/null +++ b/PadelClubData/Utils/PListReader.swift @@ -0,0 +1,47 @@ +// +// PListReader.swift +// PadelClub +// +// Created by Laurent Morvillier on 06/05/2024. +// + +import Foundation + +public 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 '\(plist)' not found in bundle") + } + return nil + + } + + public static func readString(plist: String, key: String) -> String? { + if let dictionary = self.dictionary(plist: plist) { + return dictionary[key] as? String + } + return nil + } + + public static func readBool(plist: String, key: String) -> Bool? { + if let dictionary = self.dictionary(plist: plist) { + return dictionary[key] as? Bool + } + return nil + } +} diff --git a/PadelClubData/Utils/SetDescriptor.swift b/PadelClubData/Utils/SetDescriptor.swift new file mode 100644 index 0000000..b105ed7 --- /dev/null +++ b/PadelClubData/Utils/SetDescriptor.swift @@ -0,0 +1,66 @@ +// +// SetDescriptor.swift +// PadelClub +// +// Created by Razmig Sarkissian on 02/04/2024. +// + +import Foundation + +public struct SetDescriptor: Identifiable, Equatable { + public let id: UUID = UUID() + public var valueTeamOne: Int? + public var valueTeamTwo: Int? + public var tieBreakValueTeamOne: Int? + public var tieBreakValueTeamTwo: Int? + public var setFormat: SetFormat + public var showSetInputView: Bool = true + public var showTieBreakInputView: Bool = false + + public var isTeamOneSet: Bool { + return valueTeamOne != nil || tieBreakValueTeamOne != nil + } + + public var hasEnded: Bool { + if let valueTeamTwo, let valueTeamOne { + return setFormat.hasEnded(teamOne: valueTeamOne, teamTwo: valueTeamTwo) + } else { + return false + } + } + + public var winner: TeamPosition? { + if let valueTeamTwo, let valueTeamOne, valueTeamOne != valueTeamTwo { + return setFormat.winner(teamOne: valueTeamOne, teamTwo: valueTeamTwo) + } else { + return nil + } + } + + public var shouldTieBreak: Bool { + setFormat.shouldTiebreak(scoreTeamOne: valueTeamOne ?? 0, scoreTeamTwo: valueTeamTwo ?? 0) + } + + public func getValue(teamPosition: TeamPosition) -> String? { + switch teamPosition { + case .one: + if let valueTeamOne { + if let tieBreakValueTeamOne { + return "\(valueTeamOne)-\(tieBreakValueTeamOne)" + } else { + return "\(valueTeamOne)" + } + } + case .two: + if let valueTeamTwo { + if let tieBreakValueTeamTwo { + return "\(valueTeamTwo)-\(tieBreakValueTeamTwo)" + } else { + return "\(valueTeamTwo)" + } + } + } + + return nil + } +} diff --git a/PadelClubDataTests/PadelClubDataTests.swift b/PadelClubDataTests/PadelClubDataTests.swift new file mode 100644 index 0000000..d843bdb --- /dev/null +++ b/PadelClubDataTests/PadelClubDataTests.swift @@ -0,0 +1,92 @@ +// +// PadelClubDataTests.swift +// PadelClubDataTests +// +// Created by Laurent Morvillier on 15/04/2025. +// + +import Testing +@testable import PadelClubData +@testable import LeStorage + +enum TestError: Error { + case notAuthenticated +} + +struct PadelClubDataTests { + + let username: String = "UserDataTests" + let password: String = "MyPass1234--" + + init() async throws { + StoreCenter.main.configureURLs(secureScheme: false, domain: "127.0.0.1:8000") + StoreCenter.main.tokenKeychain = MockKeychainStore(fileName: "token.json") + try await self.login() + } + + mutating func login() async throws { + let _: CustomUser = try await StoreCenter.main.service().login(username: self.username, password: self.password) + } + + @Test func testAuthentication() { + #expect(StoreCenter.main.isAuthenticated) + } + + @Test func createTournament() async throws { + + guard let userId = StoreCenter.main.userId else { + throw TestError.notAuthenticated + } + + // Cleanup + let events = DataStore.shared.events + await DataStore.shared.events.deleteAsync(contentOfs: Array(events)) + + try await DataStore.shared.events.loadDataFromServerIfAllowed(clear: true) + #expect(DataStore.shared.events.count == 0) + + try await DataStore.shared.tournaments.loadDataFromServerIfAllowed(clear: true) + #expect(DataStore.shared.tournaments.count == 0) + + // Create + let event: Event = Event(creator: userId, club: nil, name: "test") + await DataStore.shared.events.addOrUpdateAsync(instance: event) + + let tournament: Tournament = Tournament.fake() + tournament.event = event.id + await DataStore.shared.tournaments.addOrUpdateAsync(instance: tournament) + + // Test server content + try await DataStore.shared.events.loadDataFromServerIfAllowed(clear: true) + #expect(DataStore.shared.events.count == 1) + + try await DataStore.shared.tournaments.loadDataFromServerIfAllowed(clear: true) + #expect(DataStore.shared.tournaments.count == 1) + + } + + @Test func dualStoreCenter() async throws { + + let secondStoreServer = StoreCenter() + secondStoreServer.configureURLs(secureScheme: false, domain: "127.0.0.1:8000") + secondStoreServer.tokenKeychain = MockKeychainStore(fileName: "token.json") + + let _: CustomUser = try await secondStoreServer.service().login(username: self.username, password: self.password) + + #expect(StoreCenter.main.isAuthenticated) + #expect(secondStoreServer.isAuthenticated) + + } + + @Test func testWebsocketSynchronization() async throws { + + let secondStoreServer = StoreCenter() + secondStoreServer.configureURLs(secureScheme: false, domain: "127.0.0.1:8000") + secondStoreServer.tokenKeychain = MockKeychainStore(fileName: "token.json") + + let events = DataStore.shared.events + await DataStore.shared.events.deleteAsync(contentOfs: Array(events)) + + } + +} diff --git a/PadelClubDataTests/SynchronizationTests.swift b/PadelClubDataTests/SynchronizationTests.swift new file mode 100644 index 0000000..21cc0fb --- /dev/null +++ b/PadelClubDataTests/SynchronizationTests.swift @@ -0,0 +1,83 @@ +// +// SynchronizationTests.swift +// PadelClubDataTests +// +// Created by Laurent Morvillier on 17/04/2025. +// + +import Testing +@testable import PadelClubData +@testable import LeStorage + +struct SynchronizationTests { + + let username: String = "UserDataTests" + let password: String = "MyPass1234--" + + var secondStoreCenter: StoreCenter + + init() async throws { + + self.secondStoreCenter = StoreCenter(directoryName: "storage-2") + self.secondStoreCenter.configureURLs(secureScheme: false, domain: "127.0.0.1:8000", webSockets: false) + self.secondStoreCenter.tokenKeychain = MockKeychainStore(fileName: "storage-2/token.json") + self.secondStoreCenter.deviceKeychain = MockKeychainStore(fileName: "storage-2/device.json") + self.secondStoreCenter.classProject = "PadelClubData" + + let token2 = try? self.secondStoreCenter.rawTokenShouldNotBeUsed() + if token2 == nil { + try await self.login(storeCenter: self.secondStoreCenter) + } + + StoreCenter.main.configureURLs(secureScheme: false, domain: "127.0.0.1:8000", webSockets: false) + StoreCenter.main.tokenKeychain = MockKeychainStore(fileName: "storage/token.json") + StoreCenter.main.deviceKeychain = MockKeychainStore(fileName: "storage/device.json") + StoreCenter.main.classProject = "PadelClubData" + + let token = try? StoreCenter.main.rawTokenShouldNotBeUsed() + if token == nil { + try await self.login(storeCenter: StoreCenter.main) + } + } + + mutating func login(storeCenter: StoreCenter) async throws { + let _: CustomUser = try await storeCenter.service().login(username: self.username, password: self.password) + } + + @Test func testAuthentication() { + #expect(StoreCenter.main.isAuthenticated) + #expect(self.secondStoreCenter.isAuthenticated) + } + + @Test func testSynchronization() async throws { + + guard let userId = StoreCenter.main.userId else { + throw TestError.notAuthenticated + } + + // Cleanup + let eventCollection1: SyncedCollection = StoreCenter.main.mainStore.registerSynchronizedCollection(synchronized: true) + await eventCollection1.deleteAsync(contentOfs: Array(eventCollection1)) + + let eventCollection2: SyncedCollection = self.secondStoreCenter.mainStore.registerSynchronizedCollection(synchronized: true) + eventCollection2.clear() + + // Create + let event: Event = Event(creator: userId, club: nil, name: "test") + await eventCollection1.addOrUpdateAsync(instance: event) + + let serverEvents: [Event] = try await StoreCenter.main.service().get() + #expect(serverEvents.count == 1) + + try await eventCollection1.loadDataFromServerIfAllowed(clear: true) + #expect(eventCollection1.count == 1) + + try await eventCollection2.loadDataFromServerIfAllowed(clear: true) + #expect(eventCollection2.count == 1) + + try await self.secondStoreCenter.testSynchronizeOnceAsync() + #expect(eventCollection2.count == 1) + + } + +}