Compare commits

...

514 Commits

Author SHA1 Message Date
Razmig Sarkissian 745f5884ab b2 3 days ago
Razmig Sarkissian e6aaa620fe ios 26.1 fixes 3 days ago
Razmig Sarkissian a58541b5bf add some progressview 4 days ago
Razmig Sarkissian 425451424a build 2 5 days ago
Razmig Sarkissian 431a388b13 fix an issue with search player and add format helper view in toolbox 5 days ago
Razmig Sarkissian e05dfa66b8 v1.2.62 7 days ago
Razmig Sarkissian 78f31c45d3 v61 1 week ago
Razmig Sarkissian d9657ace50 fix ongoing 2 weeks ago
Razmig Sarkissian a16897f3ed v1.2.60 2 weeks ago
Razmig Sarkissian 4d366b437d add a way to filter out tournament in ongoing view 2 weeks ago
Razmig Sarkissian 00d759dd6c build 4 2 weeks ago
Razmig Sarkissian 57945f6cfd build 3 2 weeks ago
Razmig Sarkissian a5445e7280 fix issue with auto structure 2 weeks ago
Razmig Sarkissian b287b67a0c build 2 2 weeks ago
Razmig Sarkissian 41fbbc3c95 v1.2.59 2 weeks ago
Razmig Sarkissian 5d49680cca Merge remote-tracking branch 'refs/remotes/origin/main' 2 weeks ago
Razmig Sarkissian ec5fc5b5e2 fix menu option 2 weeks ago
Razmig Sarkissian 769f29c41a fix menu option 2 weeks ago
Razmig Sarkissian dd54cfa9fd v1.2.58 3 weeks ago
Razmig Sarkissian aaeebd6d75 fix planning stuff 3 weeks ago
Razmig Sarkissian 9fb5ed889e fix crash in head manager 3 weeks ago
Razmig Sarkissian 7ba7012c57 v1.2.57 3 weeks ago
Razmig Sarkissian 51deb72da0 1.2.57 build 3 3 weeks ago
Razmig Sarkissian 236406e262 small improvements 3 weeks ago
Razmig Sarkissian 9bb753cce1 build 2 3 weeks ago
Razmig Sarkissian 11914c054f v1.2.57 3 weeks ago
Razmig Sarkissian 63496d334f fix send all by event 3 weeks ago
Razmig Sarkissian ceaa03c41f add a call all event method 3 weeks ago
Razmig Sarkissian 7c3801cb51 Merge remote-tracking branch 'refs/remotes/origin/main' 3 weeks ago
Razmig Sarkissian 757f22cc67 improve call team view 3 weeks ago
Laurent 4e92e23f84 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 3 weeks ago
Laurent 46af357538 add websocket infos 3 weeks ago
Razmig Sarkissian 858a68c572 add global search 3 weeks ago
Razmig Sarkissian 413e2436dd 1.2.56 3 weeks ago
Laurent f6cf835ebf Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 3 weeks ago
Laurent 1b2fb9dc0c backup now contains part of the parent folder 3 weeks ago
Razmig Sarkissian 0de19382d8 fix request payment positionning 3 weeks ago
Razmig Sarkissian ce7fce7dfd Merge remote-tracking branch 'refs/remotes/origin/main' 4 weeks ago
Razmig Sarkissian b5d5cd4aeb add payment link api 4 weeks ago
Laurent 99cf9df1ef Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 4 weeks ago
Laurent 035f8ccc9d Adds CLAUDE.md file 4 weeks ago
Razmig Sarkissian bd03321cc0 improve export data capability for teams / players 4 weeks ago
Razmig Sarkissian 18228396bf build 2 4 weeks ago
Razmig Sarkissian dbd970f87f add custom club name option in tournament for calling teams 4 weeks ago
Razmig Sarkissian 8379eccfb6 fix registion issues not displayed 4 weeks ago
Razmig Sarkissian 43f5ac97a4 add helper footer 4 weeks ago
Razmig Sarkissian a3880b04bd fix head manager match count 4 weeks ago
Razmig Sarkissian 45319790aa fix stuff 4 weeks ago
Razmig Sarkissian b41e8064d7 some fixes 4 weeks ago
Razmig Sarkissian 6c634399d7 fix stuff headmanager 4 weeks ago
Razmig Sarkissian 13011e2b1c add heads config system 4 weeks ago
Razmig Sarkissian ac18a14863 fix toolbox debug view 4 weeks ago
Razmig Sarkissian 05f132316c add global format picker 1 month ago
Razmig Sarkissian 15ae97faf5 v1.2.55 1 month ago
Razmig Sarkissian 4badce1a06 couple of fixes 1 month ago
Razmig Sarkissian ef28a98f20 add format selection to horaire and format view 1 month ago
Razmig Sarkissian 0f14852858 fix icons 1 month ago
Razmig Sarkissian cc533081ac build 2 1 month ago
Razmig Sarkissian 44f9ab1b1c fix agenda 1 month ago
Razmig Sarkissian fbd2a083b1 fix wording 1 month ago
Razmig Sarkissian e466543628 add sharing back 1 month ago
Razmig Sarkissian 8fdeff82f1 overhaul screens disposition 1 month ago
Laurent 2183f2863f Remove sharing button 1 month ago
Laurent dec6f21db9 Adds payment when adding supervisors 1 month ago
Razmig Sarkissian b239ff9a07 v1.2.54 1 month ago
Razmig Sarkissian 15e480cf28 fix main menu 1 month ago
Razmig Sarkissian b2bc59c19e Merge remote-tracking branch 'refs/remotes/origin/sync3' 1 month ago
Razmig Sarkissian 2d40e5b816 fix shared tournament umpire stuff 1 month ago
Laurent 0520ad75a5 Merge branch 'sync3' of https://gitea.staxriver.com/staxriver/PadelClub into sync3 1 month ago
Laurent cc50cc45ac draft 1 month ago
Razmig Sarkissian 09d5da914c fix title partager 1 month ago
Razmig Sarkissian c2ccbf5dd7 improve view sharing 1 month ago
Razmig Sarkissian 154137d25f Merge branch 'main' 1 month ago
Razmig Sarkissian c7d5f4930e fix match format setup 1 month ago
Razmig Sarkissian b8680eeea1 Merge branch 'main' 1 month ago
Razmig Sarkissian d1435688eb fix payment stuff 1 month ago
Laurent 08cf60629b Implement the capacity from a user to remove himself from the sharing 1 month ago
Laurent 4a07d430b7 adds code to remove tournament from being shared 1 month ago
Laurent e7b0571e9f Bumps version to 1.2.53 1 month ago
Razmig Sarkissian 4acb1e8ea4 fix ios 26 stuff 1 month ago
Razmig Sarkissian 58f61f395f fix sharing stuff 1 month ago
Razmig Sarkissian 4441554881 Merge branch 'main' 1 month ago
Razmig Sarkissian 06dd0fc3cc fix stuff 1 month ago
Laurent a0d6580a98 fix sync issues 1 month ago
Razmig Sarkissian b9a052e7d9 Merge branch 'main' 1 month ago
Razmig Sarkissian 445a180762 fix left aligne planning view 1 month ago
Razmig Sarkissian ea13b13101 Merge branch 'main' 1 month ago
Razmig Sarkissian 052204a8d6 v1.2.53 1 month ago
Razmig Sarkissian 8602881562 fix ios 26 1 month ago
Razmig Sarkissian aaa1c16660 fix merge issue 1 month ago
Razmig Sarkissian d1bbd75015 Merge remote-tracking branch 'refs/remotes/origin/sync3' 1 month ago
Laurent f95bc4de92 merge 1 month ago
Laurent 67cfa830a8 fix build 1 month ago
Razmig Sarkissian 86c1fa26cc add subtitle to menu gestion du tournoi 1 month ago
Razmig Sarkissian 74b61c6046 remove refresh team action and task 1 month ago
Razmig Sarkissian 64d0d7c307 fix issue with merge 1 month ago
Razmig Sarkissian 0a613e6376 Merge branch 'main' 1 month ago
Razmig Sarkissian 7259777ba5 fix tournament menu 1 month ago
Razmig Sarkissian dd91369c6b animation fix in debug 1 month ago
Laurent ed161df9ba merge 1 month ago
Razmig Sarkissian 3305c9aaf4 v1.2.52 2 months ago
Razmig Sarkissian 3af1de6ff9 fix issue with ios 26 2 months ago
Razmig Sarkissian cb4e2c5ed6 Merge remote-tracking branch 'refs/remotes/origin/main' 2 months ago
Razmig Sarkissian 1601f72ad2 fix issue with ios 26 2 months ago
Laurent 9e46c3234f Payment upgrade 2 months ago
Laurent 2e2b83f804 change sync server to padelclub.app 2 months ago
Razmig Sarkissian 604d24c326 v1.2.51 2 months ago
Laurent 8987c46fae Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 2 months ago
Laurent a80e2c6945 always show offer 2 months ago
Laurent dc02001505 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 2 months ago
Laurent c5ff31a396 change default storefront for IAP 2 months ago
Laurent 488e33e253 remove network icon 2 months ago
Laurent eb7df86c50 adds pack of 10 tournaments IAP 2 months ago
Laurent 2299e941b2 Consequences of Guard changes 2 months ago
Razmig Sarkissian 463a7af43b fix issue with field setup 2 months ago
Razmig Sarkissian 29636ac374 ios 26 version 2 months ago
Laurent 3875d8558b Merge branch 'sync3' of https://gitea.staxriver.com/staxriver/PadelClub into sync3 2 months ago
Laurent a9ce9659f1 add information about sync 2 months ago
Razmig Sarkissian 7b5968b1d0 fix issue with import session 2 months ago
Razmig Sarkissian 8b7202b0eb fix issue with import session 2 months ago
Razmig Sarkissian 862b7deced fix team name playerblock view 2 months ago
Razmig Sarkissian 6237502cec fix playerblock view 2 months ago
Razmig Sarkissian 6320697c7e Merge branch 'main' 2 months ago
Razmig Sarkissian b8bf7f99e8 fix editscoreview yes 2 months ago
Razmig Sarkissian dd98548a15 fix csv export birth year FFT new system 2 months ago
Laurent c191288eb7 removes payment for summons 2 months ago
Razmig Sarkissian f6076a4230 v1.2.48 2 months ago
Razmig Sarkissian 077a56b8bb v1.2.47 3 months ago
Razmig Sarkissian 6d2d77b503 fix issue with groupstage possible crash when calculating score 3 months ago
Razmig Sarkissian ed06b68405 Merge branch 'main' 3 months ago
Razmig Sarkissian 7a08bda544 possiblité de lancer l'horaire intelligent sur tous les tournois d'un evenement en une fois 3 months ago
Razmig Sarkissian aee7b85c66 ajout du min d'équipe pour homologation 3 months ago
Razmig Sarkissian 1ce74fb235 increase number of months in tenup tournament gathering 3 to 4 3 months ago
Razmig Sarkissian 26ec624f4b update 2026 rules 3 months ago
Razmig Sarkissian 908edea494 fix issue with fft search 4 months ago
Razmig Sarkissian ae58efd2f7 fix loserbracket missing from pdf 4 months ago
Razmig Sarkissian 3781aac090 add planned date display in print options 4 months ago
Laurent faa0f8ab22 version 1.2.40 for testflight 4 months ago
Razmig Sarkissian 070d221a56 Merge remote-tracking branch 'refs/remotes/origin/main' 4 months ago
Razmig Sarkissian 331103c4c0 add contact info 4 months ago
Razmig Sarkissian a47a0c26ee add contact info 4 months ago
Laurent 892db419fe Merge branch 'main' into sync3 5 months ago
Laurent 4ee338df6d merge main 5 months ago
Razmig Sarkissian 69c0163ccb v1.2.40 5 months ago
Razmig Sarkissian 87c7c074d3 fix planning stuff 5 months ago
Razmig Sarkissian 0a3606916b fix issue with camera 5 months ago
Razmig Sarkissian 340b242665 Merge remote-tracking branch 'refs/remotes/origin/main' 5 months ago
Razmig Sarkissian 9d30f20397 v1.2.38 5 months ago
Laurent c436046ea0 Adds a soft delete button for tournaments in debug 5 months ago
Laurent d35e312c3f Merge branch 'main' into sync3 5 months ago
Razmig Sarkissian a934ad54f0 v1.2.37 5 months ago
Razmig Sarkissian fdd32440c9 fix some import stuff 5 months ago
Laurent 57439e4a93 Merge branch 'main' into sync3 5 months ago
Laurent a41080685c adds sync and fix build 5 months ago
Razmig Sarkissian da31523ded fix issue with planned date 5 months ago
Razmig Sarkissian 21289513cf fix issue with groupstage start date 5 months ago
Razmig Sarkissian 6cafb9173d v1.2.34 5 months ago
Razmig Sarkissian b611ee9afd add stat view for event / tournament 5 months ago
Razmig Sarkissian e8d41853ff add a forfait button in team group stage view 5 months ago
Laurent 07eb633ce6 Merge branch 'main' into sync3 5 months ago
Laurent 8d33bc0204 cleanup view 5 months ago
Laurent c4bd58a1af Improve tournament selection 5 months ago
Laurent a28a72075e Adds restriction for shared tournaments 5 months ago
Razmig Sarkissian 6d7c6d35b2 fix update month data rank issue 5 months ago
Razmig Sarkissian 4560ebb30a fix issue with stripe account onboarding 5 months ago
Raz 381e405d47 fix seed random pick missing for more than 32 players 6 months ago
Laurent c7575d1d67 refactoring 6 months ago
Laurent 6cefe91b37 add sharing buttons 6 months ago
Raz 235edb08f2 fix crash 6 months ago
Raz ee35304bcd fix event sharing stuff 6 months ago
Raz 4ce1d5836f Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz c59e5ecf9f v1.2.30 6 months ago
Laurent 432e78f727 adds TTC for price 6 months ago
Raz 5d9c1b9cea clean up groupstage loser bracket and init 6 months ago
Raz 0dea3ae550 add event link sharing 6 months ago
Laurent df8609d1a0 Adds sharing for matches 6 months ago
Raz b84f519929 v1.2.29 6 months ago
Raz ce3c3650ce fix payment faq wording 6 months ago
Raz 03782af8c2 fix ui for validating datepicker 6 months ago
Raz 579b4fdce2 fix wildcard pick issue 6 months ago
Raz faaff120d7 add loser bracket in pdf 6 months ago
Raz 2b3f102ac3 piste au lieu de terrains 6 months ago
Raz afa8b4bdf7 import more forgotten variables fron previous playerreg into new playerreg from beach padel 6 months ago
Raz 7cd866185e fix registration import issue 6 months ago
Raz 9cb968c441 fix live scoring 6 months ago
Raz 899a1c419c fix issue with loser round view 6 months ago
Raz 266caec83b fix issue with court setup ordering in planning 6 months ago
Raz 4f98a956b1 add csv helper 6 months ago
Raz 1b4a0204c1 fix event settings stuff 6 months ago
Raz c71f253837 v1.2.25 6 months ago
Raz f814bc84c5 update jap list export update for debug 6 months ago
Raz 12142cde37 add planning feature 6 months ago
Raz ecdc46a968 fix randomu unique index not saving 6 months ago
Raz 0fe8728a8a add accountGroupStageBreakTime groupStageRotationDifference in matchscheduler and uniqueRandomIndex in team reg 6 months ago
Raz 3d29d25f63 v1.2.24 6 months ago
Raz 6ed57be995 v1.2.24 6 months ago
Raz 323a035316 b2 6 months ago
Raz 397f30095c v1.2.23 b1 6 months ago
Raz 6b9fb6ef4c add planned start date and prog setup options 6 months ago
Raz 35a2b0f9a9 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz 28c15a7ef2 b4 6 months ago
Raz 2ec612e8b2 b4 6 months ago
Laurent 5d25f21ebb merge 6 months ago
Laurent d09153b50c re add LeStorage to embedded framework for release version to work 6 months ago
Raz 1b00a5cf97 v1.2.22 b2 6 months ago
Raz 2df66dcd9e Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz cc52f6285c add the ability to get umpire data from tenup 6 months ago
Laurent ec57e976e5 fix upload issue and framework embedding 6 months ago
Laurent 4131c4bc1a Adds purchase debugging info 6 months ago
Laurent 450966eaf5 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 6 months ago
Laurent 8b12d5e68d remove connection to websockets for release 6 months ago
Raz 01a9882d66 fix resting team issue 6 months ago
Raz 049e2a8402 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Laurent 8314f7681e fix build 6 months ago
Raz f2f9ec3821 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz 6b2c902fa3 setup framework link for all target 6 months ago
Laurent 210813bb78 Merge branch 'main' of https://gitea.staxriver.com/staxriver/PadelClub 6 months ago
Laurent fcbdb69270 fix payment info being displayed when not necessary 6 months ago
Laurent 661edd3534 Stop using websockets by default for now 6 months ago
Laurent 768b185c0a remove unused file 6 months ago
Raz 85b43ef9a2 v1.2.21 6 months ago
Raz 970e89b2e5 fix issue with event 6 months ago
Laurent 26f8a94b18 settings update 6 months ago
Laurent 9fa8e60f95 merge 6 months ago
Laurent 2c99a323b9 Split project with PadelClubData 6 months ago
Raz 5a79d6ed6a update delete account navigation 6 months ago
Raz f7a208cc6b 1.2.20 6 months ago
Raz ccf6b05e0d add losing group stage position selection consolante 6 months ago
Raz 1e41f51712 Merge remote-tracking branch 'refs/remotes/origin/main' 6 months ago
Raz a2eed6d4eb add remaining amount in event / tournament 6 months ago
Laurent 4663f32102 fix 6 months ago
Laurent 937edb440b fix build 6 months ago
Laurent 3d9658d41b Adds purchase refresh when opening app 6 months ago
Laurent d910ca1646 Refactor purchases and payments info 6 months ago
Raz 9547d13349 v1.2.19 7 months ago
Raz 901cd4e672 small fixes 7 months ago
Raz a8a7a5ac3d enable online reg for animation 7 months ago
Raz 0cbad6ef2d fix small stuff about p500 deadlines / call subject / event url listing / copy paste licence / call access before structure done / detached await task when deleting tournament 7 months ago
Raz fae48947a6 fix messaging online payment in playerdetail view 7 months ago
Raz 2dd89faa2d 1.2.16 7 months ago
Raz 91cb8e7e94 v1.2.15 7 months ago
Raz d24576eb3e fix slow stuff 7 months ago
Raz 34b72b4d66 fix init playerregistration 7 months ago
Raz 1143f51744 add missing variables in xctests 7 months ago
Raz 339efee333 fixes 7 months ago
Raz 8eca1f3d78 add some disable option 7 months ago
Raz 430af36802 fix online payment 7 months ago
Raz c1ac3ed998 keep payment data when importing from beach padel 7 months ago
Raz a5ad5736e3 fix stuff 7 months ago
Raz 62a9e7ea78 fix date update stuff 7 months ago
Raz 7988a9585c fix online payment stuff 7 months ago
Raz dafc180b61 fix stuff 7 months ago
Raz c2595cd8ed Merge branch 'main' 7 months ago
Laurent a29c2f63f4 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 7 months ago
Laurent 953dd61813 Fix issue with v 7 months ago
Raz 25ded849b5 Merge branch 'main' 7 months ago
Raz 4054ada79c Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 54e872622f clean up registration fee messages 7 months ago
Laurent f931147a20 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 7 months ago
Laurent 5dc08de697 Test for tryPutBeforeUpdating 7 months ago
Raz ea370cec79 clean up 7 months ago
Raz 3b3d8841a6 Merge branch 'main' 7 months ago
Raz 98d923afe4 fix 1 group stage ranking bug 7 months ago
Raz dde95cae2c add format edit in matchdetail view 7 months ago
Raz 6b573e4505 add user licence update 7 months ago
Raz f2f6e88d8a fix and clean up 7 months ago
Raz b95c7acb83 add refund system 7 months ago
Raz 02969cc971 clean up stripe id verification 7 months ago
Raz 4baf30647d Merge branch 'main' 7 months ago
Raz 0a5bf21b4b Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 9d31ee90d1 add payment stuff 7 months ago
Raz 629dfe5120 wip 7 months ago
Laurent 5af4d13fdf Fixes issue with copyServerResponse 7 months ago
Raz 3ea327cad2 add stuff for tenup / umpire 7 months ago
Raz af20cd8bd3 v1.2.13 7 months ago
Raz f1b013a88c fix search view model 7 months ago
Raz f72e98df8b v1.2.12 7 months ago
Raz 902578d660 1.2.11 7 months ago
Raz deb8ef473f b1 7 months ago
Raz 2bf26a9113 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 4009569e60 fix tournament lookup 7 months ago
Raz d5434d1d8f improve some fields 7 months ago
Laurent b123b24927 in debug use local.plist setting for plan 7 months ago
Laurent 73bf331a8b Bumps build number to 3 7 months ago
Laurent b3214cca7b clean up purchase api calls again 7 months ago
Raz 38cc535c89 v1.2.9 7 months ago
Raz 5e5c768843 fix user player profile detection 7 months ago
Raz ff66c524d3 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Laurent ceda7683ea fix build 7 months ago
Raz 29b2ec0124 fix groupstage crash 7 months ago
Raz 0db09bc475 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz 1da8c48cdb fix search and lic umpire view 7 months ago
Laurent c0ab6418d8 Fix crap 7 months ago
Raz ea3e76e8e2 fix search filtering 7 months ago
Laurent bee68f916f LeStorage fixes 7 months ago
Raz 92f4a37919 fix base match scheduler default value 7 months ago
Raz c428c5c5a3 fix search 7 months ago
Raz 89b0981473 fix matchscheduler 7 months ago
Raz db20896f10 Merge remote-tracking branch 'refs/remotes/origin/main' 7 months ago
Raz cd92974e14 fix 1.8 loser bracket 7 months ago
Laurent 433dcb18ed Fix tournament decoding 7 months ago
Laurent 56308b0df1 fix tournament test 7 months ago
Laurent 587fe5d995 patches tournament and upload values to server 7 months ago
Laurent b364e20aaa Fix isCanceled and payment not being properly encoded 7 months ago
Raz 0a7d9f6a66 fix 1/8eme issue 7 months ago
Raz af36a6ecc4 fix tenup stuff and signature stuff 7 months ago
Raz c4c2a5a893 fix club creation error 8 months ago
Raz 31666591b2 v1.2.4 8 months ago
Raz 6bcb25a70e fix bugs 8 months ago
Raz 1f0f8ad023 build 2 8 months ago
Raz d19b1247fd add button to import from other tournaments when no teams 8 months ago
Raz 17eb909f0f v1.2.3 8 months ago
Raz f8c4c76c47 add consolante handler 8 months ago
Raz cbbf8b970b 1.2.2 b1 8 months ago
Raz 74d6f8ba3c wip done 8 months ago
Raz acb0be6ec3 Merge branch 'main' 8 months ago
Raz 081749cc4e remove unused slow umpire stat view 8 months ago
Raz 093d67f469 fix 8 months ago
Raz fc094e5603 v1.2.2 8 months ago
Raz 87b2ee638b Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz fbd730eae6 fix issue with ts setup 8 months ago
Raz ae3052f5e8 wip 8 months ago
Laurent c60ddb772a Bumps to 1.2.1 8 months ago
Laurent 5708c9a9d8 disconnect users + remove websocket connection 8 months ago
Laurent f892a05851 Improve download view 8 months ago
Laurent f77cd9d2c9 update readme 8 months ago
Raz 4a378f8737 Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz f83cb5f251 fix issue with search 8 months ago
Laurent 186a9d6e35 update readme 8 months ago
Laurent 8bf560e566 merge main 8 months ago
Raz b27cdfa1e6 fix event list team loading 8 months ago
Laurent 5f4c0640ca Fix issue, the related user becomes the creator 8 months ago
Raz 8372030d83 fix stuff for setuping heads 8 months ago
Raz ebb4d9a367 v1.1.26 8 months ago
Raz 6ff21edba8 fix pdf rules 8 months ago
Raz 377f1eced4 add special position management 8 months ago
Laurent 37c0ac62e9 merge main 8 months ago
Laurent a6c7fddf0c hide tournament sharing buttons 8 months ago
Raz 563404d92b fix tournament deletion for the online reg tournament unpaid 8 months ago
Raz 7721302377 v1.1.25 8 months ago
Laurent 1fb273e696 LeStorage refactoring 8 months ago
Laurent e9aba1bd50 Refactor hasToken with isAuthenticated 8 months ago
Raz faa9cd8182 lock team 8 months ago
Raz 0a6d7fe797 fix table reset 8 months ago
Laurent 8c5536c99b remove print 8 months ago
Raz 51395b60a4 fix wc stuff display 8 months ago
Laurent bd48e136c8 merge main 8 months ago
Laurent 3a17267c83 add infos 8 months ago
Raz 1601b757b1 1.1.23 8 months ago
Razmig Sarkissian 83370f9e73 fix issue with update ranking and mixte tournament 8 months ago
Laurent 36504eec51 Bumps testflight target version to 1.1.22 8 months ago
Laurent ef387783c1 Merge branch 'main' into sync2 8 months ago
Razmig Sarkissian 6dccef8908 v1.1.22 8 months ago
Laurent 459a90253a minor improvements #2 8 months ago
Laurent e56c61449e minor improvements 8 months ago
Laurent 006e407ee6 Fix missing dismiss after disconnect + UI improvement 8 months ago
Laurent 6334648efd Fix memory issue when going in federal players search 8 months ago
Laurent 2b856fcde4 Fix issue where data was not deleted 8 months ago
Laurent 4dc72c0aaf Fix issue where tournament deletes kept tournament file directory 8 months ago
Laurent 602755fe2a Improve API call execution and add developer mode 8 months ago
Laurent eaabafb07e Improve API list view 8 months ago
Raz 35671d44ee remove unnecessary tip 8 months ago
Raz 8704288bf1 fix init team reg 8 months ago
Laurent a16b104757 merge main 8 months ago
Raz 5da65dbe35 v1.1.21 8 months ago
Raz 0442cf32ee Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz 7d39e94aeb v1.1.20 8 months ago
Raz 8737259186 v1.1.20 8 months ago
Raz 9aed0a118e fix bugs 8 months ago
Razmig Sarkissian 772061beba v1.1.19 8 months ago
Raz 799341c19c Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Raz c1ed22ed32 fix loadDataFromServerIfAllowed 8 months ago
Laurent 2127f089f7 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 8 months ago
Laurent 298ad297e8 Adds refresh of purchases when opening the app 8 months ago
Razmig Sarkissian 097290962c clean up 8 months ago
Razmig Sarkissian 14993d719e v1.1.18 8 months ago
Razmig Sarkissian 4a522abed9 fix stuff 8 months ago
Raz 289056bcd0 b2 8 months ago
Raz fdfe9b92ee minor fix 8 months ago
Razmig Sarkissian f7c393fc07 fix compilation issue post merge 8 months ago
Raz 76c22c22fe Merge remote-tracking branch 'refs/remotes/origin/main' 8 months ago
Razmig Sarkissian f8d41f23d0 fix online reg stuff 8 months ago
Razmig Sarkissian 41f026c013 fix start date seconds setup 8 months ago
Raz 025e22b47a fix tournament cell sttuf 8 months ago
Razmig Sarkissian 217b3dadf1 v1.1.16 8 months ago
Razmig Sarkissian 1843ab58d1 fix team label and lucky loser stuff 8 months ago
Raz e029295b6a fix crash when when replacing heads 9 months ago
Raz 018e77fda7 v1.1.14 9 months ago
Raz 981e0772e5 fix build settings 9 months ago
Raz 5c1c61d518 build 2 9 months ago
Raz b3e9c80990 fix cut display stuff 9 months ago
Laurent f46890c445 merge main 9 months ago
Raz 0afde57d8c v1.1.13 9 months ago
Raz be1db1ff71 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz 119d7e53c9 fix wildcard management 9 months ago
Laurent 7c7aee398a Bumps to 1.1.12 - 2 9 months ago
Raz a3459ba57f v1.1.11 9 months ago
Raz 5e1798af2a wip setup tournament using previous tournament preferences 9 months ago
Raz bff0e29536 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz 738b79bf9c fix stuff 9 months ago
Laurent 88a3699e46 Bumps version to 1.1.10 9 months ago
Laurent aa0955917f fix thread issue 9 months ago
Laurent 6e31435840 Adds blocking system to force app download 9 months ago
Laurent 9352975bdd Fixes tests 9 months ago
Raz 4e5dc3ea12 fix search stuff 9 months ago
Raz 8a5db5921a v1.1.9 9 months ago
Laurent ac3c21c413 merge main 9 months ago
Laurent 982520683c Adds last sync date in the debug view 9 months ago
Raz 7814195756 v1.1.8 9 months ago
Raz 715c1b69e0 fix issue with remove all seeds 9 months ago
Raz 86b3af8337 Build 2 9 months ago
Laurent 74375dec50 fix crash 9 months ago
Raz 31b13125f9 fix animation glitch rowbutton view 9 months ago
Raz 892c5e29dc change free tournament message accordingly to the 3 tournaments gift 9 months ago
Raz b5e6202906 Merge remote-tracking branch 'refs/remotes/origin/main' 9 months ago
Raz e5c113ddad remove unecessary july 2024 download 9 months ago
Laurent deb6be6e6f Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 9 months ago
Laurent 8acf5fed7d Set the number to free tournaments to 3 9 months ago
Raz 8efa0307f7 save id homologation for new ranks calc 9 months ago
Raz 86570858d1 v1.1.6 9 months ago
Raz c119e5a412 fix crash when filtering with no code club 9 months ago
Laurent edc00f0515 fix issue with disconnection by making TournamentStore optional 9 months ago
Laurent 5795a3b62f Fix crash 9 months ago
Laurent cdf89c33c4 Adds Drawlog api collection loader 9 months ago
Laurent a83e630d79 Adds SideStorable 9 months ago
Raz 70355f47de fix delete tournament issue with online reg 9 months ago
Raz 2813fcf1de v1.1.4 10 months ago
Laurent 18a0ed6d10 update readme for cascading deletes in admin 10 months ago
Laurent 4e62899b30 Fix crash when deleting 10 months ago
Raz d4e193d60b fix crash in inscription info view 10 months ago
Raz 2c4781de9d fix delete team 10 months ago
Raz b4f66d9615 v bump 10 months ago
Raz ee86d08159 fix tournament deletion 10 months ago
Raz a7e4072cc9 version bump 10 months ago
Raz ca6b56f2f9 enable realtime final ranking update 10 months ago
Raz 2ebfb79713 forcer le score du 2eme à 0 si on valide sans finir de cliquer sur le num pad (edit score) 10 months ago
Raz 4893f8bf27 v1.1.0 b2 10 months ago
Raz 2447930e60 fix player import stuff 10 months ago
Raz 993f9193da v1.1.0 10 months ago
Raz 1bbd981097 Merge branch 'main' 10 months ago
Raz 15870383e0 fix stuff 10 months ago
Laurent 37660a1f12 Fix issues 10 months ago
Laurent 06662092eb fix test 10 months ago
Laurent 68a99a495b change data test user 10 months ago
Laurent 3ef38fdaa2 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 10 months ago
Laurent f2ce6c983a fix purchase update 10 months ago
Raz d11af504b5 fixes and add legend / inscription guide 10 months ago
Raz 457900f64a wip 10 months ago
Raz 945f9ddf29 wip 10 months ago
Raz b92e5cec63 Merge branch 'main' 10 months ago
Raz 3a5af393a6 fix auto publish rankings 10 months ago
Raz ecf89f6b1d fix group stage scheduling 10 months ago
Raz 51fbc26c12 fix crash in inscription info issue view 10 months ago
Raz 9a674de3cf fix new urls 10 months ago
Raz e4825b88e6 1.0.43 10 months ago
Raz 56852e75e7 merge 10 months ago
Raz 344a1f8747 Merge branch 'main' 10 months ago
Raz 96bbe1fc32 fix fft jan 2025 10 months ago
Raz 9e2674e210 fix issue with locationbutton 10 months ago
Laurent 9271cee545 Merge branch 'main' into sync 10 months ago
Laurent 780d93dfe2 update club to use the new copy system to receive its broadcast code 10 months ago
Laurent bdea94f472 cleanup 10 months ago
Raz 783e66e754 fix unnecessary save in edit player 10 months ago
Raz e1ffa4830a fix 18.2 bug 10 months ago
Raz c8fc23164c Merge branch 'main' 10 months ago
Raz 1d8ccc97d3 fix localisation string 11 months ago
Raz fd67586ea0 fix ios 18.2 bug 11 months ago
Raz 0a1385cf13 fix ios 18.2 crash 11 months ago
Raz f5f2817292 Merge branch 'main' 11 months ago
Raz 1f21cb5b05 remove the workaround on playlist 11 months ago
Raz e5c4ea0837 fix stuff 11 months ago
Raz b4e267f695 fix online reg 11 months ago
Raz cb68d10fb3 add the ability to move time slots 11 months ago
Raz 494f083a34 remove the need to have a target team count 11 months ago
Raz dc3d193b55 addcorner radius 11 months ago
Raz 242acffe22 add tournament category settings 11 months ago
Raz 906172d9e2 quick enhance tip 11 months ago
Raz 7b06c8cefc fix registration online 11 months ago
Raz 768902e827 remove unregistered var 11 months ago
Laurent 7a0d2a8da7 Improve code 11 months ago
Laurent ce6e2281e3 sets acronym when empty 11 months ago
Laurent 1210a784d0 update readme guide 11 months ago
Raz 0ba2f9d76d v1.0.40 11 months ago
Raz b73d4e49e1 short category displayed as wide, no more one letter 11 months ago
Laurent d74c6abcfa merge main 11 months ago
Raz 75a721179f v1.0.39 11 months ago
Raz 107eabf8ff fix unecessary count check of month player data 11 months ago
Raz d5a1449b3f fix sharelink lag 11 months ago
Raz afe7aedb29 fix ranking 11 months ago
Raz 15b5ba97f9 fix senior 11 months ago
Raz 3c0fa45153 fix ranking 11 months ago
Laurent 8851315cf4 Adds patch to add storeId to needing entities 11 months ago
Laurent ce4f0d11d7 Fixes and improvements 11 months ago
Raz 2cf955f378 v1.0.38 11 months ago
Raz 607a5e65bd v1.0.37 11 months ago
Raz fc3708fbc3 fix mobile number detection 11 months ago
Laurent dfaa18d9e6 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 11 months ago
Laurent 033901d97c Fix issue 11 months ago
Raz f6cf2c45b3 v1.0.35 11 months ago
Laurent 3991bb8650 Merge branch 'main' of https://stax.alwaysdata.net/gitea/staxriver/PadelClub 11 months ago
Laurent c27a524fc5 Adds patch to reset logs 11 months ago
Laurent 9ebdffddd7 cleanup generation and add tournament sharing 11 months ago
Laurent 62fd9c5610 Adds a way to share a tournament with others 11 months ago
Laurent eb1f69ec97 Improves data generation 11 months ago
Laurent 786ad5ee45 Regeneration of classes to handle relationships 12 months ago
Laurent 3feea9b22f Change to server url configuration 12 months ago
Laurent c85dbad3ca Fix issues 1 year ago
Laurent 4b2dbf9c46 Adds TournamentLibrary to manage TournamentStore, that do not inherit Store from now on 1 year ago
Laurent fee94a2b7d add storeId for MatchScheduler 1 year ago
Laurent f1b351a13f use generated class 1 year ago
Laurent d6bd8842c3 minor update 1 year ago
Laurent 0dd3dc16bd create generator to make base Swift classes 1 year ago
Laurent 671edd3412 first commit 1 year ago
Raz 057efee144 clubs update with reset 1 year ago
  1. 8
      CLAUDE.md
  2. 983
      PadelClub.xcodeproj/project.pbxproj
  3. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub ProdTest.xcscheme
  4. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub Raw.xcscheme
  5. 4
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub TestFlight.xcscheme
  6. 2
      PadelClub.xcodeproj/xcshareddata/xcschemes/PadelClub.xcscheme
  7. 3
      PadelClub.xcworkspace/contents.xcworkspacedata
  8. 58
      PadelClub/AppDelegate.swift
  9. 33
      PadelClub/Assets.xcassets/beigeNotUniversal.colorset/Contents.json
  10. 33
      PadelClub/Assets.xcassets/grayNotUniversal.colorset/Contents.json
  11. 111
      PadelClub/Data/AppSettings.swift
  12. 203
      PadelClub/Data/Club.swift
  13. 3
      PadelClub/Data/Coredata/ImportedPlayer+Extensions.swift
  14. 87
      PadelClub/Data/Court.swift
  15. 341
      PadelClub/Data/DataStore.swift
  16. 63
      PadelClub/Data/DateInterval.swift
  17. 158
      PadelClub/Data/DrawLog.swift
  18. 130
      PadelClub/Data/Event.swift
  19. 69
      PadelClub/Data/Federal/FederalPlayer.swift
  20. 258
      PadelClub/Data/Federal/FederalTournament.swift
  21. 2
      PadelClub/Data/Federal/FederalTournamentHolder.swift
  22. 708
      PadelClub/Data/GroupStage.swift
  23. 1149
      PadelClub/Data/Match.swift
  24. 918
      PadelClub/Data/MatchScheduler.swift
  25. 66
      PadelClub/Data/MockData.swift
  26. 104
      PadelClub/Data/MonthData.swift
  27. 581
      PadelClub/Data/PlayerRegistration.swift
  28. 21
      PadelClub/Data/README.md
  29. 860
      PadelClub/Data/Round.swift
  30. 726
      PadelClub/Data/TeamRegistration.swift
  31. 98
      PadelClub/Data/TeamScore.swift
  32. 1780
      PadelClub/Data/Tournament.swift
  33. 61
      PadelClub/Data/TournamentStore.swift
  34. 250
      PadelClub/Data/User.swift
  35. 96
      PadelClub/Extensions/Array+Extensions.swift
  36. 25
      PadelClub/Extensions/Badge+Extensions.swift
  37. 71
      PadelClub/Extensions/Calendar+Extensions.swift
  38. 41
      PadelClub/Extensions/CodingContainer+Extensions.swift
  39. 22
      PadelClub/Extensions/CustomUser+Extensions.swift
  40. 262
      PadelClub/Extensions/Date+Extensions.swift
  41. 43
      PadelClub/Extensions/FixedWidthInteger+Extensions.swift
  42. 28
      PadelClub/Extensions/Locale+Extensions.swift
  43. 49
      PadelClub/Extensions/MonthData+Extensions.swift
  44. 27
      PadelClub/Extensions/MySortDescriptor.swift
  45. 16
      PadelClub/Extensions/NumberFormatter+Extensions.swift
  46. 230
      PadelClub/Extensions/PlayerRegistration+Extensions.swift
  47. 33
      PadelClub/Extensions/Round+Extensions.swift
  48. 87
      PadelClub/Extensions/Sequence+Extensions.swift
  49. 34
      PadelClub/Extensions/SourceFileManager+Extensions.swift
  50. 42
      PadelClub/Extensions/SpinDrawable+Extensions.swift
  51. 47
      PadelClub/Extensions/String+Crypto.swift
  52. 256
      PadelClub/Extensions/String+Extensions.swift
  53. 84
      PadelClub/Extensions/TeamRegistration+Extensions.swift
  54. 428
      PadelClub/Extensions/Tournament+Extensions.swift
  55. 182
      PadelClub/Extensions/URL+Extensions.swift
  56. 22
      PadelClub/Extensions/View+Extensions.swift
  57. 5
      PadelClub/HTML Templates/bracket-template.html
  58. 1
      PadelClub/HTML Templates/groupstage-template.html
  59. 14
      PadelClub/HTML Templates/match-template.html
  60. 1
      PadelClub/HTML Templates/player-template.html
  61. 36
      PadelClub/HTML Templates/tournament-template.html
  62. 2
      PadelClub/Info.plist
  63. 60
      PadelClub/OnlineRegistrationWarningView.swift
  64. 182
      PadelClub/PadelClubApp.swift
  65. 69
      PadelClub/SyncedProducts.storekit
  66. 254
      PadelClub/Utils/ContactManager.swift
  67. 12
      PadelClub/Utils/CryptoKey.swift
  68. 64
      PadelClub/Utils/DisplayContext.swift
  69. 37
      PadelClub/Utils/ExportFormat.swift
  70. 74
      PadelClub/Utils/FileImportManager.swift
  71. 23
      PadelClub/Utils/HtmlGenerator.swift
  72. 160
      PadelClub/Utils/HtmlService.swift
  73. 2
      PadelClub/Utils/LocationManager.swift
  74. 90
      PadelClub/Utils/Network/ConfigurationService.swift
  75. 302
      PadelClub/Utils/Network/FederalDataService.swift
  76. 200
      PadelClub/Utils/Network/NetworkFederalService.swift
  77. 5
      PadelClub/Utils/Network/NetworkManager.swift
  78. 28
      PadelClub/Utils/Network/NetworkManagerError.swift
  79. 77
      PadelClub/Utils/Network/PaymentService.swift
  80. 50
      PadelClub/Utils/Network/RefundService.swift
  81. 207
      PadelClub/Utils/Network/StripeValidationService.swift
  82. 62
      PadelClub/Utils/Network/XlsToCsvService.swift
  83. 47
      PadelClub/Utils/PListReader.swift
  84. 2011
      PadelClub/Utils/PadelRule.swift
  85. 164
      PadelClub/Utils/Patcher.swift
  86. 59
      PadelClub/Utils/PhoneNumbersUtils.swift
  87. 261
      PadelClub/Utils/SourceFileManager.swift
  88. 72
      PadelClub/Utils/SwiftParser.swift
  89. 177
      PadelClub/Utils/Tips.swift
  90. 86
      PadelClub/Utils/URLs.swift
  91. 33
      PadelClub/Utils/VersionComparator.swift
  92. 7
      PadelClub/ViewModel/AgendaDestination.swift
  93. 65
      PadelClub/ViewModel/FederalDataViewModel.swift
  94. 154
      PadelClub/ViewModel/MatchDescriptor.swift
  95. 3
      PadelClub/ViewModel/NavigationViewModel.swift
  96. 24
      PadelClub/ViewModel/Screen.swift
  97. 738
      PadelClub/ViewModel/SearchViewModel.swift
  98. 66
      PadelClub/ViewModel/SeedInterval.swift
  99. 89
      PadelClub/ViewModel/Selectable.swift
  100. 66
      PadelClub/ViewModel/SetDescriptor.swift
  101. Some files were not shown because too many files have changed in this diff Show More

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,428 @@
//
// Tournament+Extensions.swift
// PadelClub
//
// Created by Laurent Morvillier on 15/04/2025.
//
import Foundation
import SwiftUI
import PadelClubData
import LeStorage
extension Tournament {
func addTeam(_ players: Set<PlayerRegistration>, registrationDate: Date? = nil, name: String? = nil) -> TeamRegistration {
let team = TeamRegistration(tournament: id, registrationDate: registrationDate ?? Date(), name: name)
team.setWeight(from: Array(players), inTournamentCategory: tournamentCategory)
players.forEach { player in
player.teamRegistration = team.id
}
if isAnimation() {
if team.weight == 0 {
team.weight = unsortedTeams().count
}
}
return team
}
func addWildCardIfNeeded(_ count: Int, _ type: MatchType) {
let currentCount = selectedSortedTeams().filter({
if type == .bracket {
return $0.wildCardBracket
} else {
return $0.wildCardGroupStage
}
}).count
if currentCount < count {
let _diff = count - currentCount
addWildCard(_diff, type)
}
}
func addEmptyTeamRegistration(_ count: Int) {
guard let tournamentStore = self.tournamentStore else { return }
let teams = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
return team
}
do {
try tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams)
} catch {
Logger.error(error)
}
}
func addWildCard(_ count: Int, _ type: MatchType) {
let wcs = (0..<count).map { _ in
let team = TeamRegistration(tournament: id, registrationDate: Date())
if type == .bracket {
team.wildCardBracket = true
} else {
team.wildCardGroupStage = true
}
team.setWeight(from: [], inTournamentCategory: self.tournamentCategory)
team.weight += 200_000
return team
}
do {
try self.tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: wcs)
} catch {
Logger.error(error)
}
}
func teamsRanked() -> [TeamRegistration] {
let selected = selectedSortedTeams().filter({ $0.finalRanking != nil })
return selected.sorted(by: \.finalRanking!, order: .ascending)
}
func playersWithoutValidLicense(in players: [PlayerRegistration], isImported: Bool) -> [PlayerRegistration] {
let licenseYearValidity = self.licenseYearValidity()
return players.filter({ player in
if player.isImported() {
// Player is marked as imported: check if the license is valid
return !player.isValidLicenseNumber(year: licenseYearValidity)
} else {
// Player is not imported: validate license and handle `isImported` flag for non-imported players
let noLicenseId = player.licenceId == nil || player.licenceId?.isEmpty == true
let invalidFormattedLicense = player.formattedLicense().isLicenseNumber == false
// If global `isImported` is true, check license number as well
let invalidLicenseForImportedFlag = isImported && !player.isValidLicenseNumber(year: licenseYearValidity)
return noLicenseId || invalidFormattedLicense || invalidLicenseForImportedFlag
}
})
}
func homonyms(in players: [PlayerRegistration]) -> [PlayerRegistration] {
players.filter({ $0.hasHomonym() })
}
func payIfNecessary() async throws {
if self.payment != nil { return }
if let payment = await Guard.main.paymentForNewTournament() {
self.payment = payment
DataStore.shared.tournaments.addOrUpdate(instance: self)
return
}
throw PaymentError.cantPayTournament
}
func cutLabelColor(index: Int?, teamCount: Int?) -> Color {
guard let index else { return Color.grayNotUniversal }
let _teamCount = teamCount ?? selectedSortedTeams().count
let groupStageCut = groupStageCut()
let bracketCut = bracketCut(teamCount: _teamCount, groupStageCut: groupStageCut)
if index < bracketCut {
return Color.mint
} else if index - bracketCut < groupStageCut && _teamCount > 0 {
return Color.indigo
} else {
return Color.grayNotUniversal
}
}
func isPlayerAgeInadequate(player: PlayerHolder) -> Bool {
guard let computedAge = player.computedAge else { return false }
if federalTournamentAge.isAgeValid(age: computedAge) == false {
return true
} else {
return false
}
}
func isPlayerRankInadequate(player: PlayerHolder) -> Bool {
guard let rank = player.getRank() else { return false }
let _rank = player.male ? rank : rank + addon(for: rank, manMax: maleUnrankedValue ?? 0, womanMax: femaleUnrankedValue ?? 0)
if _rank <= tournamentLevel.minimumPlayerRank(category: tournamentCategory, ageCategory: federalTournamentAge, seasonYear: startDate.seasonYear()) {
return true
} else {
return false
}
}
func inadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerRankInadequate(player: player)
}
}
func ageInadequatePlayers(in players: [PlayerRegistration]) -> [PlayerRegistration] {
if startDate.isInCurrentYear() == false {
return []
}
return players.filter { player in
return isPlayerAgeInadequate(player: player)
}
}
func importTeams(_ teams: [FileImportManager.TeamHolder]) {
var teamsToImport = [TeamRegistration]()
let players = players().filter { $0.licenceId != nil }
teams.forEach { team in
if let previousTeam = team.previousTeam {
previousTeam.updatePlayers(team.players, inTournamentCategory: team.tournamentCategory)
teamsToImport.append(previousTeam)
} else {
var registrationDate = team.registrationDate
if let previousPlayer = players.first(where: { player in
let ids = team.players.compactMap({ $0.licenceId })
return ids.contains(player.licenceId!)
}), let previousTeamRegistrationDate = previousPlayer.team()?.registrationDate {
registrationDate = previousTeamRegistrationDate
}
let newTeam = addTeam(team.players, registrationDate: registrationDate, name: team.name)
if isAnimation() {
if newTeam.weight == 0 {
newTeam.weight = team.index(in: teams) ?? 0
}
}
teamsToImport.append(newTeam)
}
}
if let tournamentStore = self.tournamentStore {
tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teamsToImport)
let playersToImport = teams.flatMap { $0.players }
tournamentStore.playerRegistrations.addOrUpdate(contentOfs: playersToImport)
}
if state() == .build && groupStageCount > 0 && groupStageTeams().isEmpty {
setGroupStage(randomize: groupStageSortMode == .random)
}
}
func registrationIssues(selectedTeams: [TeamRegistration]) async -> Int {
let players : [PlayerRegistration] = selectedTeams.flatMap { $0.players() }
let callDateIssue : [TeamRegistration] = selectedTeams.filter { $0.callDate != nil && isStartDateIsDifferentThanCallDate($0) }
let duplicates : [PlayerRegistration] = duplicates(in: players)
let problematicPlayers : [PlayerRegistration] = players.filter({ $0.sex == nil })
let inadequatePlayers : [PlayerRegistration] = inadequatePlayers(in: players)
let homonyms = homonyms(in: players)
let ageInadequatePlayers = ageInadequatePlayers(in: players)
let isImported = players.anySatisfy({ $0.isImported() })
let playersWithoutValidLicense : [PlayerRegistration] = playersWithoutValidLicense(in: players, isImported: isImported)
let playersMissing : [TeamRegistration] = selectedTeams.filter({ $0.unsortedPlayers().count < 2 })
let waitingList : [TeamRegistration] = waitingListTeams(in: selectedTeams, includingWalkOuts: true)
let waitingListInBracket = waitingList.filter({ $0.bracketPosition != nil })
let waitingListInGroupStage = waitingList.filter({ $0.groupStage != nil })
return callDateIssue.count + duplicates.count + problematicPlayers.count + inadequatePlayers.count + playersWithoutValidLicense.count + playersMissing.count + waitingListInBracket.count + waitingListInGroupStage.count + ageInadequatePlayers.count + homonyms.count
}
func updateRank(to newDate: Date?, forceRefreshLockWeight: Bool, providedSources: [CSVParser]?) async throws {
refreshRanking = true
#if DEBUG_TIME
let start = Date()
defer {
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000)
print("func updateRank()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds])))
}
#endif
guard let newDate else { return }
rankSourceDate = newDate
// Fetch current month data only once
var monthData = currentMonthData()
if monthData == nil {
async let lastRankWoman = SourceFileManager.shared.getUnrankValue(forMale: false, rankSourceDate: rankSourceDate)
async let lastRankMan = SourceFileManager.shared.getUnrankValue(forMale: true, rankSourceDate: rankSourceDate)
let formatted = URL.importDateFormatter.string(from: newDate)
let newMonthData = MonthData(monthKey: formatted)
newMonthData.maleUnrankedValue = await lastRankMan
newMonthData.femaleUnrankedValue = await lastRankWoman
DataStore.shared.monthData.addOrUpdate(instance: newMonthData)
monthData = newMonthData
}
let lastRankMan = monthData?.maleUnrankedValue
let lastRankWoman = monthData?.femaleUnrankedValue
var chunkedParsers: [CSVParser] = []
if let providedSources {
chunkedParsers = providedSources
} else {
// Fetch only the required files
let dataURLs = SourceFileManager.shared.allFiles.filter { $0.dateFromPath == newDate }
guard !dataURLs.isEmpty else { return } // Early return if no files found
let sources = dataURLs.map { CSVParser(url: $0) }
chunkedParsers = try await chunkAllSources(sources: sources, size: 10000)
}
let players = unsortedPlayers()
for player in players {
let lastRank = (player.sex == .female) ? lastRankWoman : lastRankMan
try await player.updateRank(from: chunkedParsers, lastRank: lastRank)
player.setComputedRank(in: self)
}
if providedSources == nil {
try chunkedParsers.forEach { chunk in
try FileManager.default.removeItem(at: chunk.url)
}
}
tournamentStore?.playerRegistrations.addOrUpdate(contentOfs: players)
let unsortedTeams = unsortedTeams()
unsortedTeams.forEach { team in
team.setWeight(from: team.players(), inTournamentCategory: tournamentCategory)
if forceRefreshLockWeight {
team.lockedWeight = team.weight
}
}
tournamentStore?.teamRegistrations.addOrUpdate(contentOfs: unsortedTeams)
refreshRanking = false
}
}
extension Tournament {
static func newEmptyInstance() -> Tournament {
let lastDataSource: String? = DataStore.shared.appSettings.lastDataSource
var _mostRecentDateAvailable: Date? {
guard let lastDataSource else { return nil }
return URL.importDateFormatter.date(from: lastDataSource)
}
let rankSourceDate = _mostRecentDateAvailable
return Tournament(rankSourceDate: rankSourceDate, currencyCode: Locale.defaultCurrency())
}
}
extension Tournament: FederalTournamentHolder {
func tournamentTitle(_ displayStyle: DisplayStyle, forBuild build: any TournamentBuildHolder) -> String {
if isAnimation() {
if let name {
return name.trunc(length: DeviceHelper.charLength())
} else if build.age == .unlisted, build.category == .unlisted {
return build.level.localizedLevelLabel(.title)
} else {
return build.level.localizedLevelLabel(displayStyle)
}
}
return build.level.localizedLevelLabel(displayStyle)
}
var codeClub: String? {
club()?.code
}
var holderId: String { id }
func clubLabel() -> String {
locationLabel()
}
func subtitleLabel(forBuild build: any TournamentBuildHolder) -> String {
if isAnimation() {
if displayAgeAndCategory(forBuild: build) == false {
return [build.category.localizedCategoryLabel(ageCategory: build.age), build.age.localizedFederalAgeLabel()].filter({ $0.isEmpty == false }).joined(separator: " ")
} else if name != nil {
return build.level.localizedLevelLabel(.title)
} else {
return ""
}
} else {
return subtitle()
}
}
var tournaments: [any TournamentBuildHolder] {
[
self
]
}
var dayPeriod: DayPeriod {
let day = startDate.get(.weekday)
switch day {
case 2...6:
return .week
default:
return .weekend
}
}
func displayAgeAndCategory(forBuild build: any TournamentBuildHolder) -> Bool {
if isAnimation() {
if let name, name.count < DeviceHelper.maxCharacter() {
return true
} else if build.age == .unlisted, build.category == .unlisted {
return true
} else {
return DeviceHelper.isBigScreen()
}
}
return true
}
}
extension Tournament: TournamentBuildHolder {
public func buildHolderTitle(_ displayStyle: DisplayStyle) -> String {
tournamentTitle(.short)
}
public var category: TournamentCategory {
tournamentCategory
}
public var level: TournamentLevel {
tournamentLevel
}
public var age: FederalTournamentAge {
federalTournamentAge
}
}
// MARK: - UI extensions
extension Tournament {
public var shouldShowPaymentInfo: Bool {
if self.payment != nil {
return false
}
switch self.state() {
case .initial, .build, .running:
return true
default:
return false
}
}
}
//extension Tournament {
// func deadline(for type: TournamentDeadlineType) -> Date? {
// guard [.p500, .p1000, .p1500, .p2000].contains(tournamentLevel) else { return nil }
//
// let daysOffset = type.daysOffset(level: tournamentLevel)
// if let date = Calendar.current.date(byAdding: .day, value: daysOffset, to: startDate) {
// let startOfDay = Calendar.current.startOfDay(for: date)
// return Calendar.current.date(byAdding: type.timeOffset, to: startOfDay)
// }
// return nil
// }
//}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -8,6 +8,7 @@
import Foundation
import LeStorage
import SwiftUI
import PadelClubData
enum FileImportManagerError: LocalizedError {
case unknownFormat
@ -28,9 +29,6 @@ class ImportObserver {
func currentlyImportingLabel() -> String {
guard let currentImportDate else { return "import en cours" }
if URL.importDateFormatter.string(from: currentImportDate) == "07-2024" {
return "consolidation des données"
}
return "import " + currentImportDate.monthYearFormatted
}
@ -44,32 +42,38 @@ class ImportObserver {
class FileImportManager {
static let shared = FileImportManager()
func updatePlayers(isMale: Bool, players: inout [FederalPlayer]) {
let replacements: [(Character, Character)] = [("Á", "ç"), ("", "à"), ("Ù", "ô"), ("Ë", "è"), ("Ó", "î"), ("Î", "ë"), ("", "É"), ("Ô", "ï"), ("È", "é"), ("«", "Ç"), ("»", "È")]
var playersLeft = players
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach({ url in
if playersLeft.isEmpty == false {
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
var playersLeft = Dictionary(uniqueKeysWithValues: players.map { ($0.license, $0) })
SourceFileManager.shared.allFilesSortedByDate(isMale).forEach { url in
if playersLeft.isEmpty { return }
let federalPlayers = readCSV(inputFile: url)
let replacementsCharacters = url.dateFromPath.monthYearFormatted != "04-2024" ? [] : replacements
let federalPlayersDict = Dictionary(uniqueKeysWithValues: federalPlayers.map { ($0.license, $0) })
for (license, importedPlayer) in playersLeft {
guard let federalPlayer = federalPlayersDict[license] else { continue }
playersLeft.forEach { importedPlayer in
if let federalPlayer = federalPlayers.first(where: { $0.license == importedPlayer.license }) {
var lastName = federalPlayer.lastName
lastName.replace(characters: replacementsCharacters)
var firstName = federalPlayer.firstName
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
}
}
var lastName = federalPlayer.lastName
var firstName = federalPlayer.firstName
lastName.replace(characters: replacementsCharacters)
firstName.replace(characters: replacementsCharacters)
importedPlayer.lastName = lastName.trimmed.uppercased()
importedPlayer.firstName = firstName.trimmed.capitalized
playersLeft.removeValue(forKey: license) // Remove processed player
}
})
}
players = playersLeft
players = Array(playersLeft.values)
}
func foundInWomenData(license: String?) -> Bool {
guard let license = license?.strippedLicense else {
return false
@ -128,14 +132,16 @@ class FileImportManager {
let weight: Int
let tournamentCategory: TournamentCategory
let tournamentAgeCategory: FederalTournamentAge
let tournamentLevel: TournamentLevel
let previousTeam: TeamRegistration?
var registrationDate: Date? = nil
var name: String? = nil
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
init(players: [PlayerRegistration], tournamentCategory: TournamentCategory, tournamentAgeCategory: FederalTournamentAge, tournamentLevel: TournamentLevel, previousTeam: TeamRegistration?, registrationDate: Date? = nil, name: String? = nil, tournament: Tournament) {
self.players = Set(players)
self.tournamentCategory = tournamentCategory
self.tournamentAgeCategory = tournamentAgeCategory
self.tournamentLevel = tournamentLevel
self.name = name
self.previousTeam = previousTeam
if players.count < 2 {
@ -148,7 +154,7 @@ class FileImportManager {
}
let significantPlayerCount = 2
let pl = players.prefix(significantPlayerCount).map { $0.computedRank }
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 70_000 : 10_000) }).prefix(significantPlayerCount)
let missingPl = (missing.map { tournament.unrankValue(for: $0 == 1 ? true : false ) ?? ($0 == 1 ? 92_327 : 10_000) }).prefix(significantPlayerCount)
self.weight = pl.reduce(0,+) + missingPl.reduce(0,+)
} else {
self.weight = players.map { $0.computedRank }.reduce(0,+)
@ -303,12 +309,14 @@ class FileImportManager {
if (tournamentCategory == tournament.tournamentCategory && tournamentAgeCategory == tournament.federalTournamentAge) || checkingCategoryDisabled {
let playerOne = PlayerRegistration(federalData: Array(resultOne[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(resultTwo[0...7]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -364,12 +372,14 @@ class FileImportManager {
let playerOne = PlayerRegistration(federalData: Array(result[0...7]), sex: sexPlayerOne, sexUnknown: sexUnknown)
playerOne?.setComputedRank(in: tournament)
playerOne?.setClubMember(for: tournament)
let playerTwo = PlayerRegistration(federalData: Array(result[8...]), sex: sexPlayerTwo, sexUnknown: sexUnknown)
playerTwo?.setComputedRank(in: tournament)
playerTwo?.setClubMember(for: tournament)
let players = [playerOne, playerTwo].compactMap({ $0 })
if players.isEmpty == false {
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, previousTeam: tournament.findTeam(players), tournament: tournament)
let team = TeamHolder(players: players, tournamentCategory: tournamentCategory, tournamentAgeCategory: tournamentAgeCategory, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(players), tournament: tournament)
results.append(team)
}
}
@ -400,6 +410,7 @@ class FileImportManager {
let registeredPlayers = found?.map({ importedPlayer in
let player = PlayerRegistration(importedPlayer: importedPlayer)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
return player
})
if let registeredPlayers, registeredPlayers.isEmpty == false {
@ -417,7 +428,7 @@ class FileImportManager {
return nil
}
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
let team = TeamHolder(players: registeredPlayers, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: tournament.findTeam(registeredPlayers), registrationDate: registrationDate, tournament: tournament)
results.append(team)
}
}
@ -447,7 +458,7 @@ class FileImportManager {
let data = player.components(separatedBy: separator)
let lastName : String = data[safe: 2]?.prefixTrimmed(50) ?? ""
let firstName : String = data[safe: 3]?.prefixTrimmed(50) ?? ""
let sex: PlayerRegistration.PlayerSexType = data[safe: 0] == "f" ? PlayerRegistration.PlayerSexType.female : PlayerRegistration.PlayerSexType.male
let sex: PlayerSexType = data[safe: 0] == "f" ? PlayerSexType.female : PlayerSexType.male
if data[safe: 1]?.trimmed != nil {
teamName = data[safe: 1]?.trimmed
}
@ -462,6 +473,7 @@ class FileImportManager {
if let found, autoSearch {
let player = PlayerRegistration(importedPlayer: found)
player.setComputedRank(in: tournament)
player.setClubMember(for: tournament)
player.email = email
player.phoneNumber = phoneNumber
return player
@ -476,7 +488,7 @@ class FileImportManager {
}
}
return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, previousTeam: nil, name: teamName, tournament: tournament)
return TeamHolder(players: players, tournamentCategory: tournament.tournamentCategory, tournamentAgeCategory: tournament.federalTournamentAge, tournamentLevel: tournament.tournamentLevel, previousTeam: nil, name: teamName, tournament: tournament)
}
return results
}

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

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

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

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

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

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

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

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

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

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

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

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

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

File diff suppressed because it is too large Load Diff

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading…
Cancel
Save