Compare commits
812 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
745f5884ab | 3 days ago |
|
|
e6aaa620fe | 3 days ago |
|
|
a58541b5bf | 4 days ago |
|
|
425451424a | 5 days ago |
|
|
431a388b13 | 5 days ago |
|
|
e05dfa66b8 | 7 days ago |
|
|
78f31c45d3 | 2 weeks ago |
|
|
d9657ace50 | 2 weeks ago |
|
|
a16897f3ed | 2 weeks ago |
|
|
4d366b437d | 2 weeks ago |
|
|
00d759dd6c | 2 weeks ago |
|
|
57945f6cfd | 2 weeks ago |
|
|
a5445e7280 | 2 weeks ago |
|
|
b287b67a0c | 2 weeks ago |
|
|
41fbbc3c95 | 2 weeks ago |
|
|
5d49680cca | 2 weeks ago |
|
|
ec5fc5b5e2 | 2 weeks ago |
|
|
769f29c41a | 2 weeks ago |
|
|
dd54cfa9fd | 3 weeks ago |
|
|
aaeebd6d75 | 3 weeks ago |
|
|
9fb5ed889e | 3 weeks ago |
|
|
7ba7012c57 | 3 weeks ago |
|
|
51deb72da0 | 3 weeks ago |
|
|
236406e262 | 3 weeks ago |
|
|
9bb753cce1 | 3 weeks ago |
|
|
11914c054f | 3 weeks ago |
|
|
63496d334f | 3 weeks ago |
|
|
ceaa03c41f | 3 weeks ago |
|
|
7c3801cb51 | 3 weeks ago |
|
|
757f22cc67 | 3 weeks ago |
|
|
4e92e23f84 | 3 weeks ago |
|
|
46af357538 | 3 weeks ago |
|
|
858a68c572 | 3 weeks ago |
|
|
413e2436dd | 3 weeks ago |
|
|
f6cf835ebf | 3 weeks ago |
|
|
1b2fb9dc0c | 3 weeks ago |
|
|
0de19382d8 | 3 weeks ago |
|
|
ce7fce7dfd | 4 weeks ago |
|
|
b5d5cd4aeb | 4 weeks ago |
|
|
99cf9df1ef | 4 weeks ago |
|
|
035f8ccc9d | 4 weeks ago |
|
|
bd03321cc0 | 4 weeks ago |
|
|
18228396bf | 4 weeks ago |
|
|
dbd970f87f | 4 weeks ago |
|
|
8379eccfb6 | 4 weeks ago |
|
|
43f5ac97a4 | 4 weeks ago |
|
|
a3880b04bd | 4 weeks ago |
|
|
45319790aa | 4 weeks ago |
|
|
b41e8064d7 | 4 weeks ago |
|
|
6c634399d7 | 4 weeks ago |
|
|
13011e2b1c | 4 weeks ago |
|
|
ac18a14863 | 4 weeks ago |
|
|
05f132316c | 1 month ago |
|
|
15ae97faf5 | 1 month ago |
|
|
4badce1a06 | 1 month ago |
|
|
ef28a98f20 | 1 month ago |
|
|
0f14852858 | 1 month ago |
|
|
cc533081ac | 1 month ago |
|
|
44f9ab1b1c | 1 month ago |
|
|
fbd2a083b1 | 1 month ago |
|
|
e466543628 | 1 month ago |
|
|
8fdeff82f1 | 1 month ago |
|
|
2183f2863f | 1 month ago |
|
|
dec6f21db9 | 1 month ago |
|
|
b239ff9a07 | 1 month ago |
|
|
15e480cf28 | 1 month ago |
|
|
b2bc59c19e | 1 month ago |
|
|
2d40e5b816 | 1 month ago |
|
|
0520ad75a5 | 1 month ago |
|
|
cc50cc45ac | 1 month ago |
|
|
09d5da914c | 1 month ago |
|
|
c2ccbf5dd7 | 1 month ago |
|
|
154137d25f | 1 month ago |
|
|
c7d5f4930e | 1 month ago |
|
|
b8680eeea1 | 1 month ago |
|
|
d1435688eb | 1 month ago |
|
|
08cf60629b | 1 month ago |
|
|
4a07d430b7 | 1 month ago |
|
|
e7b0571e9f | 1 month ago |
|
|
4acb1e8ea4 | 1 month ago |
|
|
58f61f395f | 1 month ago |
|
|
4441554881 | 1 month ago |
|
|
06dd0fc3cc | 1 month ago |
|
|
a0d6580a98 | 1 month ago |
|
|
b9a052e7d9 | 1 month ago |
|
|
445a180762 | 1 month ago |
|
|
ea13b13101 | 1 month ago |
|
|
052204a8d6 | 1 month ago |
|
|
8602881562 | 1 month ago |
|
|
aaa1c16660 | 1 month ago |
|
|
d1bbd75015 | 1 month ago |
|
|
f95bc4de92 | 1 month ago |
|
|
67cfa830a8 | 1 month ago |
|
|
86c1fa26cc | 1 month ago |
|
|
74b61c6046 | 1 month ago |
|
|
64d0d7c307 | 1 month ago |
|
|
0a613e6376 | 1 month ago |
|
|
7259777ba5 | 1 month ago |
|
|
dd91369c6b | 1 month ago |
|
|
ed161df9ba | 1 month ago |
|
|
3305c9aaf4 | 2 months ago |
|
|
3af1de6ff9 | 2 months ago |
|
|
cb4e2c5ed6 | 2 months ago |
|
|
1601f72ad2 | 2 months ago |
|
|
9e46c3234f | 2 months ago |
|
|
2e2b83f804 | 2 months ago |
|
|
604d24c326 | 2 months ago |
|
|
8987c46fae | 2 months ago |
|
|
a80e2c6945 | 2 months ago |
|
|
dc02001505 | 2 months ago |
|
|
c5ff31a396 | 2 months ago |
|
|
488e33e253 | 2 months ago |
|
|
eb7df86c50 | 2 months ago |
|
|
2299e941b2 | 2 months ago |
|
|
463a7af43b | 2 months ago |
|
|
29636ac374 | 2 months ago |
|
|
3875d8558b | 2 months ago |
|
|
a9ce9659f1 | 2 months ago |
|
|
7b5968b1d0 | 2 months ago |
|
|
8b7202b0eb | 2 months ago |
|
|
862b7deced | 2 months ago |
|
|
6237502cec | 2 months ago |
|
|
6320697c7e | 2 months ago |
|
|
b8bf7f99e8 | 2 months ago |
|
|
dd98548a15 | 2 months ago |
|
|
c191288eb7 | 2 months ago |
|
|
f6076a4230 | 2 months ago |
|
|
077a56b8bb | 3 months ago |
|
|
6d2d77b503 | 3 months ago |
|
|
ed06b68405 | 3 months ago |
|
|
7a08bda544 | 3 months ago |
|
|
aee7b85c66 | 3 months ago |
|
|
1ce74fb235 | 3 months ago |
|
|
26ec624f4b | 3 months ago |
|
|
908edea494 | 4 months ago |
|
|
ae58efd2f7 | 4 months ago |
|
|
3781aac090 | 4 months ago |
|
|
faa0f8ab22 | 4 months ago |
|
|
070d221a56 | 4 months ago |
|
|
331103c4c0 | 4 months ago |
|
|
a47a0c26ee | 4 months ago |
|
|
892db419fe | 5 months ago |
|
|
4ee338df6d | 5 months ago |
|
|
69c0163ccb | 5 months ago |
|
|
87c7c074d3 | 5 months ago |
|
|
0a3606916b | 5 months ago |
|
|
340b242665 | 5 months ago |
|
|
9d30f20397 | 5 months ago |
|
|
c436046ea0 | 5 months ago |
|
|
d35e312c3f | 5 months ago |
|
|
a934ad54f0 | 5 months ago |
|
|
fdd32440c9 | 5 months ago |
|
|
57439e4a93 | 5 months ago |
|
|
a41080685c | 5 months ago |
|
|
da31523ded | 5 months ago |
|
|
21289513cf | 5 months ago |
|
|
6cafb9173d | 5 months ago |
|
|
b611ee9afd | 5 months ago |
|
|
e8d41853ff | 5 months ago |
|
|
07eb633ce6 | 5 months ago |
|
|
8d33bc0204 | 5 months ago |
|
|
c4bd58a1af | 5 months ago |
|
|
a28a72075e | 5 months ago |
|
|
6d7c6d35b2 | 5 months ago |
|
|
4560ebb30a | 5 months ago |
|
|
381e405d47 | 6 months ago |
|
|
c7575d1d67 | 6 months ago |
|
|
6cefe91b37 | 6 months ago |
|
|
235edb08f2 | 6 months ago |
|
|
ee35304bcd | 6 months ago |
|
|
4ce1d5836f | 6 months ago |
|
|
c59e5ecf9f | 6 months ago |
|
|
432e78f727 | 6 months ago |
|
|
5d9c1b9cea | 6 months ago |
|
|
0dea3ae550 | 6 months ago |
|
|
df8609d1a0 | 6 months ago |
|
|
b84f519929 | 6 months ago |
|
|
ce3c3650ce | 6 months ago |
|
|
03782af8c2 | 6 months ago |
|
|
579b4fdce2 | 6 months ago |
|
|
faaff120d7 | 6 months ago |
|
|
2b3f102ac3 | 6 months ago |
|
|
afa8b4bdf7 | 6 months ago |
|
|
7cd866185e | 6 months ago |
|
|
9cb968c441 | 6 months ago |
|
|
899a1c419c | 6 months ago |
|
|
266caec83b | 6 months ago |
|
|
4f98a956b1 | 6 months ago |
|
|
1b4a0204c1 | 6 months ago |
|
|
c71f253837 | 6 months ago |
|
|
f814bc84c5 | 6 months ago |
|
|
12142cde37 | 6 months ago |
|
|
ecdc46a968 | 6 months ago |
|
|
0fe8728a8a | 6 months ago |
|
|
3d29d25f63 | 6 months ago |
|
|
6ed57be995 | 6 months ago |
|
|
323a035316 | 6 months ago |
|
|
397f30095c | 6 months ago |
|
|
6b9fb6ef4c | 6 months ago |
|
|
35a2b0f9a9 | 6 months ago |
|
|
28c15a7ef2 | 6 months ago |
|
|
2ec612e8b2 | 6 months ago |
|
|
5d25f21ebb | 6 months ago |
|
|
d09153b50c | 6 months ago |
|
|
1b00a5cf97 | 6 months ago |
|
|
2df66dcd9e | 6 months ago |
|
|
cc52f6285c | 6 months ago |
|
|
ec57e976e5 | 6 months ago |
|
|
4131c4bc1a | 6 months ago |
|
|
450966eaf5 | 6 months ago |
|
|
8b12d5e68d | 6 months ago |
|
|
01a9882d66 | 6 months ago |
|
|
049e2a8402 | 6 months ago |
|
|
8314f7681e | 6 months ago |
|
|
f2f9ec3821 | 6 months ago |
|
|
6b2c902fa3 | 6 months ago |
|
|
210813bb78 | 6 months ago |
|
|
fcbdb69270 | 6 months ago |
|
|
661edd3534 | 6 months ago |
|
|
768b185c0a | 6 months ago |
|
|
85b43ef9a2 | 6 months ago |
|
|
970e89b2e5 | 6 months ago |
|
|
26f8a94b18 | 6 months ago |
|
|
9fa8e60f95 | 6 months ago |
|
|
2c99a323b9 | 6 months ago |
|
|
5a79d6ed6a | 6 months ago |
|
|
f7a208cc6b | 6 months ago |
|
|
ccf6b05e0d | 6 months ago |
|
|
1e41f51712 | 6 months ago |
|
|
a2eed6d4eb | 6 months ago |
|
|
4663f32102 | 6 months ago |
|
|
937edb440b | 6 months ago |
|
|
3d9658d41b | 6 months ago |
|
|
d910ca1646 | 6 months ago |
|
|
9547d13349 | 7 months ago |
|
|
901cd4e672 | 7 months ago |
|
|
a8a7a5ac3d | 7 months ago |
|
|
0cbad6ef2d | 7 months ago |
|
|
fae48947a6 | 7 months ago |
|
|
2dd89faa2d | 7 months ago |
|
|
91cb8e7e94 | 7 months ago |
|
|
d24576eb3e | 7 months ago |
|
|
34b72b4d66 | 7 months ago |
|
|
1143f51744 | 7 months ago |
|
|
339efee333 | 7 months ago |
|
|
8eca1f3d78 | 7 months ago |
|
|
430af36802 | 7 months ago |
|
|
c1ac3ed998 | 7 months ago |
|
|
a5ad5736e3 | 7 months ago |
|
|
62a9e7ea78 | 7 months ago |
|
|
7988a9585c | 7 months ago |
|
|
dafc180b61 | 7 months ago |
|
|
c2595cd8ed | 7 months ago |
|
|
a29c2f63f4 | 7 months ago |
|
|
953dd61813 | 7 months ago |
|
|
25ded849b5 | 7 months ago |
|
|
4054ada79c | 7 months ago |
|
|
54e872622f | 7 months ago |
|
|
f931147a20 | 7 months ago |
|
|
5dc08de697 | 7 months ago |
|
|
ea370cec79 | 7 months ago |
|
|
3b3d8841a6 | 7 months ago |
|
|
98d923afe4 | 7 months ago |
|
|
dde95cae2c | 7 months ago |
|
|
6b573e4505 | 7 months ago |
|
|
f2f6e88d8a | 7 months ago |
|
|
b95c7acb83 | 7 months ago |
|
|
02969cc971 | 7 months ago |
|
|
4baf30647d | 7 months ago |
|
|
0a5bf21b4b | 7 months ago |
|
|
9d31ee90d1 | 7 months ago |
|
|
629dfe5120 | 7 months ago |
|
|
5af4d13fdf | 7 months ago |
|
|
3ea327cad2 | 7 months ago |
|
|
af20cd8bd3 | 7 months ago |
|
|
f1b013a88c | 7 months ago |
|
|
f72e98df8b | 7 months ago |
|
|
902578d660 | 7 months ago |
|
|
deb8ef473f | 7 months ago |
|
|
2bf26a9113 | 7 months ago |
|
|
4009569e60 | 7 months ago |
|
|
d5434d1d8f | 7 months ago |
|
|
b123b24927 | 7 months ago |
|
|
73bf331a8b | 7 months ago |
|
|
b3214cca7b | 7 months ago |
|
|
38cc535c89 | 7 months ago |
|
|
5e5c768843 | 7 months ago |
|
|
ff66c524d3 | 7 months ago |
|
|
ceda7683ea | 7 months ago |
|
|
29b2ec0124 | 7 months ago |
|
|
0db09bc475 | 7 months ago |
|
|
1da8c48cdb | 7 months ago |
|
|
c0ab6418d8 | 7 months ago |
|
|
ea3e76e8e2 | 7 months ago |
|
|
bee68f916f | 7 months ago |
|
|
92f4a37919 | 7 months ago |
|
|
c428c5c5a3 | 7 months ago |
|
|
89b0981473 | 7 months ago |
|
|
db20896f10 | 7 months ago |
|
|
cd92974e14 | 7 months ago |
|
|
433dcb18ed | 7 months ago |
|
|
56308b0df1 | 7 months ago |
|
|
587fe5d995 | 7 months ago |
|
|
b364e20aaa | 7 months ago |
|
|
0a7d9f6a66 | 7 months ago |
|
|
af36a6ecc4 | 7 months ago |
|
|
c4c2a5a893 | 8 months ago |
|
|
31666591b2 | 8 months ago |
|
|
6bcb25a70e | 8 months ago |
|
|
1f0f8ad023 | 8 months ago |
|
|
d19b1247fd | 8 months ago |
|
|
17eb909f0f | 8 months ago |
|
|
f8c4c76c47 | 8 months ago |
|
|
cbbf8b970b | 8 months ago |
|
|
74d6f8ba3c | 8 months ago |
|
|
acb0be6ec3 | 8 months ago |
|
|
081749cc4e | 8 months ago |
|
|
093d67f469 | 8 months ago |
|
|
fc094e5603 | 8 months ago |
|
|
87b2ee638b | 8 months ago |
|
|
fbd730eae6 | 8 months ago |
|
|
ae3052f5e8 | 8 months ago |
|
|
c60ddb772a | 8 months ago |
|
|
5708c9a9d8 | 8 months ago |
|
|
f892a05851 | 8 months ago |
|
|
f77cd9d2c9 | 8 months ago |
|
|
4a378f8737 | 8 months ago |
|
|
f83cb5f251 | 8 months ago |
|
|
186a9d6e35 | 8 months ago |
|
|
8bf560e566 | 8 months ago |
|
|
b27cdfa1e6 | 8 months ago |
|
|
5f4c0640ca | 8 months ago |
|
|
8372030d83 | 8 months ago |
|
|
ebb4d9a367 | 8 months ago |
|
|
6ff21edba8 | 8 months ago |
|
|
377f1eced4 | 8 months ago |
|
|
37c0ac62e9 | 8 months ago |
|
|
a6c7fddf0c | 8 months ago |
|
|
563404d92b | 8 months ago |
|
|
7721302377 | 8 months ago |
|
|
1fb273e696 | 8 months ago |
|
|
e9aba1bd50 | 8 months ago |
|
|
faa9cd8182 | 8 months ago |
|
|
0a6d7fe797 | 8 months ago |
|
|
8c5536c99b | 8 months ago |
|
|
51395b60a4 | 8 months ago |
|
|
bd48e136c8 | 8 months ago |
|
|
3a17267c83 | 8 months ago |
|
|
1601b757b1 | 8 months ago |
|
|
83370f9e73 | 8 months ago |
|
|
36504eec51 | 8 months ago |
|
|
ef387783c1 | 8 months ago |
|
|
6dccef8908 | 8 months ago |
|
|
459a90253a | 8 months ago |
|
|
e56c61449e | 8 months ago |
|
|
006e407ee6 | 8 months ago |
|
|
6334648efd | 8 months ago |
|
|
2b856fcde4 | 8 months ago |
|
|
4dc72c0aaf | 8 months ago |
|
|
602755fe2a | 8 months ago |
|
|
eaabafb07e | 8 months ago |
|
|
35671d44ee | 8 months ago |
|
|
8704288bf1 | 8 months ago |
|
|
a16b104757 | 8 months ago |
|
|
5da65dbe35 | 8 months ago |
|
|
0442cf32ee | 8 months ago |
|
|
7d39e94aeb | 8 months ago |
|
|
8737259186 | 8 months ago |
|
|
9aed0a118e | 8 months ago |
|
|
772061beba | 8 months ago |
|
|
799341c19c | 8 months ago |
|
|
c1ed22ed32 | 8 months ago |
|
|
2127f089f7 | 8 months ago |
|
|
298ad297e8 | 8 months ago |
|
|
097290962c | 8 months ago |
|
|
14993d719e | 8 months ago |
|
|
4a522abed9 | 8 months ago |
|
|
289056bcd0 | 8 months ago |
|
|
fdfe9b92ee | 8 months ago |
|
|
f7c393fc07 | 8 months ago |
|
|
76c22c22fe | 8 months ago |
|
|
f8d41f23d0 | 8 months ago |
|
|
41f026c013 | 8 months ago |
|
|
025e22b47a | 8 months ago |
|
|
217b3dadf1 | 8 months ago |
|
|
1843ab58d1 | 8 months ago |
|
|
e029295b6a | 9 months ago |
|
|
018e77fda7 | 9 months ago |
|
|
981e0772e5 | 9 months ago |
|
|
5c1c61d518 | 9 months ago |
|
|
b3e9c80990 | 9 months ago |
|
|
f46890c445 | 9 months ago |
|
|
0afde57d8c | 9 months ago |
|
|
be1db1ff71 | 9 months ago |
|
|
119d7e53c9 | 9 months ago |
|
|
7c7aee398a | 9 months ago |
|
|
a3459ba57f | 9 months ago |
|
|
5e1798af2a | 9 months ago |
|
|
bff0e29536 | 9 months ago |
|
|
738b79bf9c | 9 months ago |
|
|
88a3699e46 | 9 months ago |
|
|
aa0955917f | 9 months ago |
|
|
6e31435840 | 9 months ago |
|
|
9352975bdd | 9 months ago |
|
|
4e5dc3ea12 | 9 months ago |
|
|
8a5db5921a | 9 months ago |
|
|
ac3c21c413 | 9 months ago |
|
|
982520683c | 9 months ago |
|
|
7814195756 | 9 months ago |
|
|
715c1b69e0 | 9 months ago |
|
|
86b3af8337 | 9 months ago |
|
|
74375dec50 | 9 months ago |
|
|
31b13125f9 | 9 months ago |
|
|
892c5e29dc | 9 months ago |
|
|
b5e6202906 | 9 months ago |
|
|
e5c113ddad | 9 months ago |
|
|
deb6be6e6f | 9 months ago |
|
|
8acf5fed7d | 9 months ago |
|
|
8efa0307f7 | 9 months ago |
|
|
86570858d1 | 9 months ago |
|
|
c119e5a412 | 9 months ago |
|
|
edc00f0515 | 9 months ago |
|
|
5795a3b62f | 9 months ago |
|
|
cdf89c33c4 | 9 months ago |
|
|
a83e630d79 | 9 months ago |
|
|
70355f47de | 9 months ago |
|
|
2813fcf1de | 10 months ago |
|
|
18a0ed6d10 | 10 months ago |
|
|
4e62899b30 | 10 months ago |
|
|
d4e193d60b | 10 months ago |
|
|
2c4781de9d | 10 months ago |
|
|
b4f66d9615 | 10 months ago |
|
|
ee86d08159 | 10 months ago |
|
|
a7e4072cc9 | 10 months ago |
|
|
ca6b56f2f9 | 10 months ago |
|
|
2ebfb79713 | 10 months ago |
|
|
4893f8bf27 | 10 months ago |
|
|
2447930e60 | 10 months ago |
|
|
993f9193da | 10 months ago |
|
|
1bbd981097 | 10 months ago |
|
|
15870383e0 | 10 months ago |
|
|
37660a1f12 | 10 months ago |
|
|
06662092eb | 10 months ago |
|
|
68a99a495b | 10 months ago |
|
|
3ef38fdaa2 | 10 months ago |
|
|
f2ce6c983a | 10 months ago |
|
|
d11af504b5 | 10 months ago |
|
|
457900f64a | 10 months ago |
|
|
945f9ddf29 | 10 months ago |
|
|
b92e5cec63 | 10 months ago |
|
|
3a5af393a6 | 10 months ago |
|
|
ecf89f6b1d | 10 months ago |
|
|
51fbc26c12 | 10 months ago |
|
|
9a674de3cf | 10 months ago |
|
|
e4825b88e6 | 10 months ago |
|
|
56852e75e7 | 10 months ago |
|
|
344a1f8747 | 10 months ago |
|
|
96bbe1fc32 | 10 months ago |
|
|
9e2674e210 | 10 months ago |
|
|
9271cee545 | 10 months ago |
|
|
780d93dfe2 | 10 months ago |
|
|
bdea94f472 | 10 months ago |
|
|
783e66e754 | 10 months ago |
|
|
e1ffa4830a | 10 months ago |
|
|
c8fc23164c | 10 months ago |
|
|
1d8ccc97d3 | 11 months ago |
|
|
fd67586ea0 | 11 months ago |
|
|
0a1385cf13 | 11 months ago |
|
|
f5f2817292 | 11 months ago |
|
|
1f21cb5b05 | 11 months ago |
|
|
e5c4ea0837 | 11 months ago |
|
|
b4e267f695 | 11 months ago |
|
|
cb68d10fb3 | 11 months ago |
|
|
494f083a34 | 11 months ago |
|
|
dc3d193b55 | 11 months ago |
|
|
242acffe22 | 11 months ago |
|
|
906172d9e2 | 11 months ago |
|
|
7b06c8cefc | 11 months ago |
|
|
768902e827 | 11 months ago |
|
|
7a0d2a8da7 | 11 months ago |
|
|
ce6e2281e3 | 11 months ago |
|
|
1210a784d0 | 11 months ago |
|
|
0ba2f9d76d | 11 months ago |
|
|
b73d4e49e1 | 11 months ago |
|
|
d74c6abcfa | 11 months ago |
|
|
75a721179f | 11 months ago |
|
|
107eabf8ff | 11 months ago |
|
|
d5a1449b3f | 11 months ago |
|
|
afe7aedb29 | 11 months ago |
|
|
15b5ba97f9 | 11 months ago |
|
|
3c0fa45153 | 11 months ago |
|
|
8851315cf4 | 11 months ago |
|
|
a356d9768c | 11 months ago |
|
|
78624bd422 | 11 months ago |
|
|
07a0b632f9 | 11 months ago |
|
|
ce4f0d11d7 | 11 months ago |
|
|
2cf955f378 | 11 months ago |
|
|
607a5e65bd | 11 months ago |
|
|
fc3708fbc3 | 11 months ago |
|
|
dfaa18d9e6 | 11 months ago |
|
|
033901d97c | 11 months ago |
|
|
f6cf2c45b3 | 11 months ago |
|
|
3991bb8650 | 11 months ago |
|
|
c27a524fc5 | 11 months ago |
|
|
9ebdffddd7 | 11 months ago |
|
|
62fd9c5610 | 11 months ago |
|
|
eb1f69ec97 | 11 months ago |
|
|
2c1b00c4cf | 11 months ago |
|
|
11acae92b0 | 11 months ago |
|
|
83e61f375a | 11 months ago |
|
|
0ff0d3fd81 | 11 months ago |
|
|
a0a6200dc9 | 11 months ago |
|
|
5f0eaa156b | 11 months ago |
|
|
9a8f9f8949 | 12 months ago |
|
|
420b5e9bc2 | 12 months ago |
|
|
2381031452 | 12 months ago |
|
|
acacfe1de2 | 12 months ago |
|
|
7a4628208c | 12 months ago |
|
|
786ad5ee45 | 12 months ago |
|
|
28e94cb348 | 12 months ago |
|
|
3feea9b22f | 12 months ago |
|
|
58ffb56ef5 | 12 months ago |
|
|
928b3510e2 | 12 months ago |
|
|
e8665a3e8b | 12 months ago |
|
|
c5bc2fb0d5 | 12 months ago |
|
|
16de4ee012 | 12 months ago |
|
|
e852999739 | 12 months ago |
|
|
17e0c85e0b | 12 months ago |
|
|
41b62a73ae | 1 year ago |
|
|
c85dbad3ca | 1 year ago |
|
|
4b2dbf9c46 | 1 year ago |
|
|
fee94a2b7d | 1 year ago |
|
|
87e4adf270 | 1 year ago |
|
|
3d00c58eb2 | 1 year ago |
|
|
f1b351a13f | 1 year ago |
|
|
41e356bf28 | 1 year ago |
|
|
f60daee81f | 1 year ago |
|
|
1fb4b7294e | 1 year ago |
|
|
42a7487c63 | 1 year ago |
|
|
0e5988b4ed | 1 year ago |
|
|
d6e87daa3d | 1 year ago |
|
|
969fa5094f | 1 year ago |
|
|
877bc33ea9 | 1 year ago |
|
|
e12dbff90d | 1 year ago |
|
|
884f1d9186 | 1 year ago |
|
|
619702bcff | 1 year ago |
|
|
0c0b492d8f | 1 year ago |
|
|
b6e8144bfd | 1 year ago |
|
|
1f77e287e0 | 1 year ago |
|
|
e25bcb47b4 | 1 year ago |
|
|
407d73a765 | 1 year ago |
|
|
ef20c838c4 | 1 year ago |
|
|
dc9e735711 | 1 year ago |
|
|
a5e09d2c06 | 1 year ago |
|
|
835bf8fd3f | 1 year ago |
|
|
d6bd8842c3 | 1 year ago |
|
|
31f093e60e | 1 year ago |
|
|
9a47f8c87d | 1 year ago |
|
|
af1ea610b4 | 1 year ago |
|
|
566c5c1eac | 1 year ago |
|
|
c016e5e0c8 | 1 year ago |
|
|
4143236154 | 1 year ago |
|
|
0dd3dc16bd | 1 year ago |
|
|
eb451f6655 | 1 year ago |
|
|
b7f2b33816 | 1 year ago |
|
|
dced9bb0be | 1 year ago |
|
|
2195bb6037 | 1 year ago |
|
|
8fbafd8c08 | 1 year ago |
|
|
28b2d79d2a | 1 year ago |
|
|
52c8162a2d | 1 year ago |
|
|
883fdaea18 | 1 year ago |
|
|
ea807778fe | 1 year ago |
|
|
978f90dfcb | 1 year ago |
|
|
b219fef6c4 | 1 year ago |
|
|
ee8a297f13 | 1 year ago |
|
|
5496bf2e96 | 1 year ago |
|
|
77b3f27685 | 1 year ago |
|
|
82e1f6a342 | 1 year ago |
|
|
9291d63d05 | 1 year ago |
|
|
8eadd30759 | 1 year ago |
|
|
585cb9cf9f | 1 year ago |
|
|
021708c02f | 1 year ago |
|
|
d4fedd862b | 1 year ago |
|
|
b5041db0db | 1 year ago |
|
|
7b2989d29b | 1 year ago |
|
|
91c4c5b591 | 1 year ago |
|
|
86f95c319a | 1 year ago |
|
|
e72bc97bd7 | 1 year ago |
|
|
a51adc6bbe | 1 year ago |
|
|
671edd3412 | 1 year ago |
|
|
20eeea99b8 | 1 year ago |
|
|
af1c53f1b8 | 1 year ago |
|
|
6791aed10a | 1 year ago |
|
|
87758354c6 | 1 year ago |
|
|
87b46d7ccb | 1 year ago |
|
|
7811806925 | 1 year ago |
|
|
f174ccfe57 | 1 year ago |
|
|
7c3725ce5b | 1 year ago |
|
|
17f371e450 | 1 year ago |
|
|
138551c32a | 1 year ago |
|
|
93036ecdd5 | 1 year ago |
|
|
47198e9b88 | 1 year ago |
|
|
1d70070a68 | 1 year ago |
|
|
50a0f8a6ae | 1 year ago |
|
|
fe5ba820e4 | 1 year ago |
|
|
df0a0b5e56 | 1 year ago |
|
|
673e405c2d | 1 year ago |
|
|
91a3ec1700 | 1 year ago |
|
|
5d2a52dce7 | 1 year ago |
|
|
a7af5c6b1c | 1 year ago |
|
|
e1d9b15072 | 1 year ago |
|
|
34ad82f177 | 1 year ago |
|
|
996220fe6f | 1 year ago |
|
|
ad5b2f06c4 | 1 year ago |
|
|
131ee06fd4 | 1 year ago |
|
|
9b0bb5efe5 | 1 year ago |
|
|
ff5da277db | 1 year ago |
|
|
ac60d64acc | 1 year ago |
|
|
c025559b5e | 1 year ago |
|
|
b3144be82d | 1 year ago |
|
|
a68ffa20c5 | 1 year ago |
|
|
8cf59a31c8 | 1 year ago |
|
|
176df45214 | 1 year ago |
|
|
412df66c1e | 1 year ago |
|
|
bfb5d78b04 | 1 year ago |
|
|
c21d4688e3 | 1 year ago |
|
|
31ee3cef62 | 1 year ago |
|
|
5f35af7fb2 | 1 year ago |
|
|
4c5459ed6a | 1 year ago |
|
|
bfcd3a1d6b | 1 year ago |
|
|
b3f07a18cc | 1 year ago |
|
|
dbaf775003 | 1 year ago |
|
|
ea4772e3d4 | 1 year ago |
|
|
b823a10bf5 | 1 year ago |
|
|
276ec87bc7 | 1 year ago |
|
|
f528e0aacd | 1 year ago |
|
|
91426c9990 | 1 year ago |
|
|
ca1a3a87f4 | 1 year ago |
|
|
a9735c1c21 | 1 year ago |
|
|
d77779835d | 1 year ago |
|
|
44f4ec028b | 1 year ago |
|
|
b87c9003c8 | 1 year ago |
|
|
aa301c9e04 | 1 year ago |
|
|
0719d1a85d | 1 year ago |
|
|
9419b60507 | 1 year ago |
|
|
c089e05c1a | 1 year ago |
|
|
be4ee15485 | 1 year ago |
|
|
74ee3a4525 | 1 year ago |
|
|
8d67d7efab | 1 year ago |
|
|
d5ea4f5336 | 1 year ago |
|
|
c9b25227d7 | 1 year ago |
|
|
ea671ae14d | 1 year ago |
|
|
a01dfcea59 | 1 year ago |
|
|
8dec31dd14 | 1 year ago |
|
|
6e68226ac7 | 1 year ago |
|
|
eca125ef73 | 1 year ago |
|
|
5ec65c88d5 | 1 year ago |
|
|
6f929c44cf | 1 year ago |
|
|
b2f38febc8 | 1 year ago |
|
|
13be596b26 | 1 year ago |
|
|
6ac26eb1e4 | 1 year ago |
|
|
0e8cd58f74 | 1 year ago |
|
|
a0477d4fa3 | 1 year ago |
|
|
dd4501b95c | 1 year ago |
|
|
d3ed0147be | 1 year ago |
|
|
3638aae3c3 | 1 year ago |
|
|
883a46baea | 1 year ago |
|
|
f691681e94 | 1 year ago |
|
|
8ca2193579 | 1 year ago |
|
|
46032d767b | 1 year ago |
|
|
b335aebcae | 1 year ago |
|
|
886ba02498 | 1 year ago |
|
|
93e529c993 | 1 year ago |
|
|
f59f0d4aaf | 1 year ago |
|
|
940f9ed146 | 1 year ago |
|
|
290aad40a4 | 1 year ago |
|
|
f764e91384 | 1 year ago |
|
|
a3f76e4a77 | 1 year ago |
|
|
4d625cb19a | 1 year ago |
|
|
0a816b20e1 | 1 year ago |
|
|
913d3de4f5 | 1 year ago |
|
|
daec637301 | 1 year ago |
|
|
0aed24a003 | 1 year ago |
|
|
f64f7387b5 | 1 year ago |
|
|
62e2b59875 | 1 year ago |
|
|
32d81a01e8 | 1 year ago |
|
|
af9f9a1747 | 1 year ago |
|
|
4f3aec3e19 | 1 year ago |
|
|
4c4e472c09 | 1 year ago |
|
|
0545b621b9 | 1 year ago |
|
|
0352fffc4d | 1 year ago |
|
|
3b62bd8a79 | 1 year ago |
|
|
051065b453 | 1 year ago |
|
|
7f3c190e3f | 1 year ago |
|
|
3106ad2175 | 1 year ago |
|
|
03b3e13a36 | 1 year ago |
|
|
9eecd8f624 | 1 year ago |
|
|
c37fcb1373 | 1 year ago |
|
|
d422684df0 | 1 year ago |
|
|
fe737d2493 | 1 year ago |
|
|
42e1598fb4 | 1 year ago |
|
|
493b595d5f | 1 year ago |
|
|
be89a1bda0 | 1 year ago |
|
|
e950669132 | 1 year ago |
|
|
6dc3a35628 | 1 year ago |
|
|
7f2a3dfc0a | 1 year ago |
|
|
bb76b3a1fa | 1 year ago |
|
|
447f57067d | 1 year ago |
|
|
92161a1ee4 | 1 year ago |
|
|
e4e909df3e | 1 year ago |
|
|
77d722db50 | 1 year ago |
|
|
32577b810b | 1 year ago |
|
|
cf3cb3ec18 | 1 year ago |
|
|
3a7409ef0f | 1 year ago |
|
|
ec9518e41f | 1 year ago |
|
|
6391ace9e0 | 1 year ago |
|
|
0161203e84 | 1 year ago |
|
|
acda8c2530 | 1 year ago |
|
|
0593ceb58d | 1 year ago |
|
|
19b413ff3e | 1 year ago |
|
|
393069ec45 | 1 year ago |
|
|
09c4012990 | 1 year ago |
|
|
b74c7a3c74 | 1 year ago |
|
|
d059002342 | 1 year ago |
|
|
ef91eb6f04 | 1 year ago |
|
|
10c86dd15c | 1 year ago |
|
|
da0ec5b231 | 1 year ago |
|
|
e7f2ae8ee1 | 1 year ago |
|
|
cce0f095c2 | 1 year ago |
|
|
f856463662 | 1 year ago |
|
|
2f14a16852 | 1 year ago |
|
|
a6177c22ac | 1 year ago |
|
|
640de8a8d0 | 1 year ago |
|
|
d8d43b7112 | 1 year ago |
|
|
5f01323616 | 1 year ago |
|
|
cc427dbdea | 1 year ago |
|
|
24e79b1eb8 | 1 year ago |
|
|
3eea8d2976 | 1 year ago |
|
|
0a76d3c9e3 | 1 year ago |
|
|
90aee596f1 | 1 year ago |
|
|
19c548598f | 1 year ago |
|
|
badd580de7 | 1 year ago |
|
|
1dba6633ee | 1 year ago |
|
|
edc99d1e79 | 1 year ago |
|
|
9c2b93ad62 | 1 year ago |
|
|
5469ce290c | 1 year ago |
|
|
d4bae677d4 | 1 year ago |
|
|
556f57004d | 1 year ago |
|
|
09d7852c87 | 1 year ago |
|
|
89a70fbbba | 1 year ago |
|
|
07460ba67a | 1 year ago |
|
|
550aa91c56 | 1 year ago |
|
|
26836e0dd7 | 1 year ago |
|
|
b8262de8bf | 1 year ago |
|
|
3cdc8e945e | 1 year ago |
|
|
7b1a5cd37f | 1 year ago |
|
|
3e503bfcd1 | 1 year ago |
|
|
fdfaebe44c | 1 year ago |
|
|
68338f580a | 1 year ago |
|
|
470aa619d6 | 1 year ago |
|
|
6b354884d8 | 1 year ago |
|
|
933cf13df7 | 1 year ago |
|
|
63f5b509d2 | 1 year ago |
|
|
8fc6f09908 | 1 year ago |
|
|
7d4ccf0f67 | 1 year ago |
|
|
c9b812cd7e | 1 year ago |
|
|
8e301a4f24 | 1 year ago |
|
|
708e0aa481 | 1 year ago |
|
|
40fc73e6bf | 1 year ago |
|
|
9a2d293b41 | 1 year ago |
|
|
ae0fb47c74 | 1 year ago |
|
|
85f437dc08 | 1 year ago |
|
|
289a4fa99d | 1 year ago |
|
|
486286db83 | 1 year ago |
|
|
a442cd934d | 1 year ago |
|
|
bf1f934704 | 1 year ago |
|
|
d4a40a20b6 | 1 year ago |
|
|
c8cd6c1af1 | 1 year ago |
|
|
ac641cfca0 | 1 year ago |
|
|
e318df1eb5 | 1 year ago |
|
|
29dc0bf8bf | 1 year ago |
|
|
eec308bb8b | 1 year ago |
|
|
888672cc9b | 1 year ago |
|
|
f03621124a | 1 year ago |
|
|
48a5dd4cc3 | 1 year ago |
|
|
143a17f5b4 | 1 year ago |
|
|
0860fb5bca | 1 year ago |
|
|
ba87b39c76 | 1 year ago |
|
|
ff0b483369 | 1 year ago |
|
|
e1a504f63f | 1 year ago |
|
|
36213d2713 | 1 year ago |
|
|
7c3321b3b1 | 1 year ago |
|
|
32447ef056 | 1 year ago |
|
|
6238e603c9 | 1 year ago |
|
|
a1f53ab8fb | 1 year ago |
|
|
fbca6c3e21 | 1 year ago |
|
|
5c5c741fba | 1 year ago |
|
|
37e184e199 | 1 year ago |
|
|
b15497f303 | 1 year ago |
|
|
47a11aca64 | 1 year ago |
|
|
60eb840604 | 1 year ago |
|
|
71822ac204 | 1 year ago |
|
|
e800b5b4ff | 1 year ago |
|
|
f64a600f16 | 1 year ago |
|
|
48c865ae33 | 1 year ago |
|
|
17c4911a89 | 1 year ago |
|
|
de19ce4384 | 1 year ago |
|
|
3d707fea8c | 1 year ago |
|
|
54b18e264f | 1 year ago |
|
|
1dd2b1ee5d | 1 year ago |
|
|
6bf9bdf634 | 1 year ago |
|
|
057efee144 | 1 year ago |
@ -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
@ -0,0 +1,78 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<Scheme |
||||
LastUpgradeVersion = "1630" |
||||
version = "1.7"> |
||||
<BuildAction |
||||
parallelizeBuildables = "YES" |
||||
buildImplicitDependencies = "YES" |
||||
buildArchitectures = "Automatic"> |
||||
<BuildActionEntries> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "YES" |
||||
buildForProfiling = "YES" |
||||
buildForArchiving = "YES" |
||||
buildForAnalyzing = "YES"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF4CBF3E2C996C0600151637" |
||||
BuildableName = "ProdTest PadelClub.app" |
||||
BlueprintName = "ProdTest PadelClub" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
</BuildActionEntries> |
||||
</BuildAction> |
||||
<TestAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
shouldAutocreateTestPlan = "YES"> |
||||
</TestAction> |
||||
<LaunchAction |
||||
buildConfiguration = "Release" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
launchStyle = "0" |
||||
useCustomWorkingDirectory = "NO" |
||||
ignoresPersistentStateOnLaunch = "NO" |
||||
debugDocumentVersioning = "YES" |
||||
debugServiceExtension = "internal" |
||||
allowLocationSimulation = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF4CBF3E2C996C0600151637" |
||||
BuildableName = "ProdTest PadelClub.app" |
||||
BlueprintName = "ProdTest PadelClub" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</LaunchAction> |
||||
<ProfileAction |
||||
buildConfiguration = "Release" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
savedToolIdentifier = "" |
||||
useCustomWorkingDirectory = "NO" |
||||
debugDocumentVersioning = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF4CBF3E2C996C0600151637" |
||||
BuildableName = "ProdTest PadelClub.app" |
||||
BlueprintName = "ProdTest PadelClub" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</ProfileAction> |
||||
<AnalyzeAction |
||||
buildConfiguration = "Debug"> |
||||
</AnalyzeAction> |
||||
<ArchiveAction |
||||
buildConfiguration = "Release" |
||||
revealArchiveInOrganizer = "YES"> |
||||
</ArchiveAction> |
||||
</Scheme> |
||||
@ -0,0 +1,78 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<Scheme |
||||
LastUpgradeVersion = "1630" |
||||
version = "1.7"> |
||||
<BuildAction |
||||
parallelizeBuildables = "YES" |
||||
buildImplicitDependencies = "YES" |
||||
buildArchitectures = "Automatic"> |
||||
<BuildActionEntries> |
||||
<BuildActionEntry |
||||
buildForTesting = "YES" |
||||
buildForRunning = "YES" |
||||
buildForProfiling = "YES" |
||||
buildForArchiving = "YES" |
||||
buildForAnalyzing = "YES"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildActionEntry> |
||||
</BuildActionEntries> |
||||
</BuildAction> |
||||
<TestAction |
||||
buildConfiguration = "Debug" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
shouldAutocreateTestPlan = "YES"> |
||||
</TestAction> |
||||
<LaunchAction |
||||
buildConfiguration = "Release" |
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" |
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" |
||||
launchStyle = "0" |
||||
useCustomWorkingDirectory = "NO" |
||||
ignoresPersistentStateOnLaunch = "NO" |
||||
debugDocumentVersioning = "YES" |
||||
debugServiceExtension = "internal" |
||||
allowLocationSimulation = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</LaunchAction> |
||||
<ProfileAction |
||||
buildConfiguration = "Release" |
||||
shouldUseLaunchSchemeArgsEnv = "YES" |
||||
savedToolIdentifier = "" |
||||
useCustomWorkingDirectory = "NO" |
||||
debugDocumentVersioning = "YES"> |
||||
<BuildableProductRunnable |
||||
runnableDebuggingMode = "0"> |
||||
<BuildableReference |
||||
BuildableIdentifier = "primary" |
||||
BlueprintIdentifier = "FF70FABD2C90584900129CC2" |
||||
BuildableName = "PadelClub TestFlight.app" |
||||
BlueprintName = "PadelClub TestFlight" |
||||
ReferencedContainer = "container:PadelClub.xcodeproj"> |
||||
</BuildableReference> |
||||
</BuildableProductRunnable> |
||||
</ProfileAction> |
||||
<AnalyzeAction |
||||
buildConfiguration = "Debug"> |
||||
</AnalyzeAction> |
||||
<ArchiveAction |
||||
buildConfiguration = "Release" |
||||
revealArchiveInOrganizer = "YES"> |
||||
</ArchiveAction> |
||||
</Scheme> |
||||
@ -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,35 +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 |
||||
|
||||
required init() { |
||||
|
||||
} |
||||
|
||||
required init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
lastDataSource = try container.decodeIfPresent(String.self, forKey: ._lastDataSource) |
||||
didCreateAccount = try container.decodeIfPresent(Bool.self, forKey: ._didCreateAccount) ?? false |
||||
didRegisterAccount = try container.decodeIfPresent(Bool.self, forKey: ._didRegisterAccount) ?? false |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _lastDataSource = "lastDataSource" |
||||
case _didCreateAccount = "didCreateAccount" |
||||
case _didRegisterAccount = "didRegisterAccount" |
||||
} |
||||
} |
||||
@ -1,248 +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 alphabeticalName: Bool = false |
||||
|
||||
internal init(creator: String? = nil, name: String, acronym: String? = nil, phone: String? = nil, code: String? = nil, address: String? = nil, city: String? = nil, zipCode: String? = nil, latitude: Double? = nil, longitude: Double? = nil, courtCount: Int = 2, broadcastCode: String? = nil) { |
||||
self.name = name |
||||
self.creator = creator |
||||
self.acronym = acronym ?? name.acronym() |
||||
self.phone = phone |
||||
self.code = code |
||||
self.address = address |
||||
self.city = city |
||||
self.zipCode = zipCode |
||||
self.latitude = latitude |
||||
self.longitude = longitude |
||||
self.courtCount = courtCount |
||||
self.broadcastCode = broadcastCode |
||||
} |
||||
|
||||
override func copyFromServerInstance(_ instance: any Storable) -> Bool { |
||||
guard let copy = instance as? Club else { return false } |
||||
self.broadcastCode = copy.broadcastCode |
||||
// Logger.log("write code: \(self.broadcastCode)") |
||||
return true |
||||
} |
||||
|
||||
func clubTitle(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return name |
||||
case .short: |
||||
return acronym |
||||
} |
||||
} |
||||
|
||||
func shareURL() -> URL? { |
||||
return URL(string: URLs.main.url.appending(path: "?club=\(id)").absoluteString.removingPercentEncoding!) |
||||
} |
||||
|
||||
var customizedCourts: [Court] { |
||||
DataStore.shared.courts.filter { $0.club == self.id }.sorted(by: \.index) |
||||
} |
||||
|
||||
override func deleteDependencies() throws { |
||||
let customizedCourts = self.customizedCourts |
||||
for customizedCourt in customizedCourts { |
||||
try customizedCourt.deleteDependencies() |
||||
} |
||||
DataStore.shared.courts.deleteDependencies(customizedCourts) |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _creator = "creator" |
||||
case _name = "name" |
||||
case _acronym = "acronym" |
||||
case _phone = "phone" |
||||
case _code = "code" |
||||
case _address = "address" |
||||
case _city = "city" |
||||
case _zipCode = "zipCode" |
||||
case _latitude = "latitude" |
||||
case _longitude = "longitude" |
||||
case _courtCount = "courtCount" |
||||
case _broadcastCode = "broadcastCode" |
||||
// case _alphabeticalName = "alphabeticalName" |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
|
||||
if let creator = creator { |
||||
try container.encode(creator, forKey: ._creator) |
||||
} else { |
||||
try container.encodeNil(forKey: ._creator) |
||||
} |
||||
|
||||
try container.encode(name, forKey: ._name) |
||||
try container.encode(acronym, forKey: ._acronym) |
||||
|
||||
if let phone = phone { |
||||
try container.encode(phone, forKey: ._phone) |
||||
} else { |
||||
try container.encodeNil(forKey: ._phone) |
||||
} |
||||
|
||||
if let code = code { |
||||
try container.encode(code, forKey: ._code) |
||||
} else { |
||||
try container.encodeNil(forKey: ._code) |
||||
} |
||||
|
||||
if let address = address { |
||||
try container.encode(address, forKey: ._address) |
||||
} else { |
||||
try container.encodeNil(forKey: ._address) |
||||
} |
||||
|
||||
if let city = city { |
||||
try container.encode(city, forKey: ._city) |
||||
} else { |
||||
try container.encodeNil(forKey: ._city) |
||||
} |
||||
|
||||
if let zipCode = zipCode { |
||||
try container.encode(zipCode, forKey: ._zipCode) |
||||
} else { |
||||
try container.encodeNil(forKey: ._zipCode) |
||||
} |
||||
|
||||
if let latitude = latitude { |
||||
try container.encode(latitude, forKey: ._latitude) |
||||
} else { |
||||
try container.encodeNil(forKey: ._latitude) |
||||
} |
||||
|
||||
if let longitude = longitude { |
||||
try container.encode(longitude, forKey: ._longitude) |
||||
} else { |
||||
try container.encodeNil(forKey: ._longitude) |
||||
} |
||||
|
||||
try container.encode(courtCount, forKey: ._courtCount) |
||||
|
||||
if let broadcastCode { |
||||
try container.encode(broadcastCode, forKey: ._broadcastCode) |
||||
} else { |
||||
try container.encodeNil(forKey: ._broadcastCode) |
||||
} |
||||
|
||||
// try container.encode(alphabeticalName, forKey: ._alphabeticalName) |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Club { |
||||
var isValid: Bool { |
||||
name.isEmpty == false && name.count > 3 |
||||
} |
||||
|
||||
func automaticShortName() -> String { |
||||
name.acronym() |
||||
} |
||||
|
||||
enum AcronymMode: String, CaseIterable { |
||||
case automatic = "Automatique" |
||||
case custom = "Personalisée" |
||||
} |
||||
|
||||
func shortNameMode() -> AcronymMode { |
||||
(acronym.isEmpty || acronym == automaticShortName()) ? .automatic : .custom |
||||
} |
||||
|
||||
func hasTenupId() -> Bool { |
||||
code != nil |
||||
} |
||||
|
||||
func federalLink() -> URL? { |
||||
guard let code else { return nil } |
||||
return URL(string: "https://tenup.fft.fr/club/\(code)") |
||||
} |
||||
|
||||
func courtName(atIndex courtIndex: Int) -> String { |
||||
courtNameIfAvailable(atIndex: courtIndex) ?? Court.courtIndexedTitle(atIndex: courtIndex) |
||||
} |
||||
|
||||
func courtNameIfAvailable(atIndex courtIndex: Int) -> String? { |
||||
customizedCourts.first(where: { $0.index == courtIndex })?.name |
||||
} |
||||
|
||||
func update(fromClub club: Club) { |
||||
self.acronym = club.acronym |
||||
self.name = club.name |
||||
self.phone = club.phone |
||||
self.code = club.code |
||||
self.address = club.address |
||||
self.city = club.city |
||||
self.zipCode = club.zipCode |
||||
self.latitude = club.latitude |
||||
self.longitude = club.longitude |
||||
} |
||||
|
||||
func hasBeenCreated(by creatorId: String?) -> Bool { |
||||
return creatorId == creator || creator == nil |
||||
} |
||||
|
||||
func isFavorite() -> Bool { |
||||
return DataStore.shared.user.clubs.contains(where: { $0 == self.id }) |
||||
} |
||||
|
||||
static func findOrCreate(name: String, code: String?, city: String? = nil, zipCode: String? = nil) -> Club { |
||||
|
||||
/* |
||||
|
||||
identify a club : code, name, ?? |
||||
|
||||
*/ |
||||
let club: Club? = DataStore.shared.clubs.first(where: { (code == nil && $0.name == name && $0.city == city && $0.zipCode == zipCode) || code != nil && $0.code == code }) |
||||
|
||||
if let club { |
||||
return club |
||||
} else { |
||||
return Club(creator: StoreCenter.main.userId, name: name, code: code, city: city, zipCode: zipCode) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,8 @@ |
||||
<?xml version="1.0" encoding="UTF-8"?> |
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
||||
<plist version="1.0"> |
||||
<dict> |
||||
<key>_XCCurrentVersionName</key> |
||||
<string>Model_1_1.xcdatamodel</string> |
||||
</dict> |
||||
</plist> |
||||
@ -0,0 +1,30 @@ |
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?> |
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="22757" systemVersion="23E214" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="v1.1"> |
||||
<entity name="ImportedPlayer" representedClassName=".ImportedPlayer" syncable="YES" codeGenerationType="class"> |
||||
<attribute name="assimilation" attributeType="String"/> |
||||
<attribute name="bestRank" optional="YES" attributeType="String"/> |
||||
<attribute name="birthYear" optional="YES" attributeType="String"/> |
||||
<attribute name="canonicalFirstName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(firstName)"/> |
||||
<attribute name="canonicalFullName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(fullName)"/> |
||||
<attribute name="canonicalLastName" optional="YES" attributeType="String" derived="YES" derivationExpression="canonical:(lastName)"/> |
||||
<attribute name="clubCode" attributeType="String"/> |
||||
<attribute name="clubName" attributeType="String"/> |
||||
<attribute name="country" attributeType="String"/> |
||||
<attribute name="firstName" attributeType="String"/> |
||||
<attribute name="fullName" attributeType="String"/> |
||||
<attribute name="importDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/> |
||||
<attribute name="lastName" attributeType="String"/> |
||||
<attribute name="license" attributeType="String"/> |
||||
<attribute name="ligueName" attributeType="String"/> |
||||
<attribute name="male" attributeType="Boolean" usesScalarValueType="YES"/> |
||||
<attribute name="points" optional="YES" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/> |
||||
<attribute name="progression" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> |
||||
<attribute name="rank" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> |
||||
<attribute name="tournamentCount" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/> |
||||
<uniquenessConstraints> |
||||
<uniquenessConstraint> |
||||
<constraint value="license"/> |
||||
</uniquenessConstraint> |
||||
</uniquenessConstraints> |
||||
</entity> |
||||
</model> |
||||
@ -1,93 +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) |
||||
|
||||
if let name = name { |
||||
try container.encode(name, forKey: ._name) |
||||
} else { |
||||
try container.encodeNil(forKey: ._name) |
||||
} |
||||
|
||||
try container.encode(exitAllowed, forKey: ._exitAllowed) |
||||
try container.encode(indoor, forKey: ._indoor) |
||||
} |
||||
|
||||
} |
||||
@ -1,297 +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 var userStorage: StoredSingleton<User> |
||||
|
||||
fileprivate var _temporaryLocalUser: OptionalStorage<User> = OptionalStorage(fileName: "tmp_local_user.json") |
||||
fileprivate(set) var appSettingsStorage: MicroStorage<AppSettings> = MicroStorage(fileName: "appsettings.json") |
||||
|
||||
var appSettings: AppSettings { |
||||
appSettingsStorage.item |
||||
} |
||||
|
||||
init() { |
||||
let store = Store.main |
||||
let serverURL: String = URLs.api.rawValue |
||||
StoreCenter.main.blackListUserName("apple-test") |
||||
|
||||
#if DEBUG |
||||
if let server = PListReader.readString(plist: "local", key: "server") { |
||||
StoreCenter.main.synchronizationApiURL = server |
||||
} else { |
||||
StoreCenter.main.synchronizationApiURL = serverURL |
||||
} |
||||
#else |
||||
StoreCenter.main.synchronizationApiURL = serverURL |
||||
#endif |
||||
|
||||
StoreCenter.main.logsFailedAPICalls() |
||||
|
||||
var synchronized: Bool = true |
||||
|
||||
_ = Guard.main // init |
||||
|
||||
#if DEBUG |
||||
if let sync = PListReader.readBool(plist: "local", key: "synchronized") { |
||||
synchronized = sync |
||||
} |
||||
#endif |
||||
|
||||
Logger.log("Sync URL: \(StoreCenter.main.synchronizationApiURL ?? "none"), sync: \(synchronized) ") |
||||
|
||||
let indexed: Bool = true |
||||
self.clubs = store.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.courts = store.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.tournaments = store.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.events = store.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.monthData = store.registerCollection(synchronized: false, indexed: indexed) |
||||
self.dateIntervals = store.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
|
||||
self.userStorage = store.registerObject(synchronized: synchronized) |
||||
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(collectionDidLoad), name: NSNotification.Name.CollectionDidLoad, object: nil) |
||||
NotificationCenter.default.addObserver(self, selector: #selector(collectionDidUpdate), name: NSNotification.Name.CollectionDidChange, object: nil) |
||||
|
||||
} |
||||
|
||||
func saveUser() { |
||||
do { |
||||
if user.username.count > 0 { |
||||
try self.userStorage.update() |
||||
} else { |
||||
self._temporaryLocalUser.item = self.user |
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
@objc func collectionDidLoad(notification: Notification) { |
||||
|
||||
DispatchQueue.main.async { |
||||
self.objectWillChange.send() |
||||
} |
||||
|
||||
if let userSingleton: StoredSingleton<User> = notification.object as? StoredSingleton<User> { |
||||
self.user = userSingleton.item() ?? self._temporaryLocalUser.item ?? User.placeHolder() |
||||
} else if let clubsCollection: StoredCollection<Club> = notification.object as? StoredCollection<Club> { |
||||
self._fixMissingClubCreatorIfNecessary(clubsCollection) |
||||
} else if let eventsCollection: StoredCollection<Event> = notification.object as? StoredCollection<Event> { |
||||
self._fixMissingEventCreatorIfNecessary(eventsCollection) |
||||
} |
||||
|
||||
if Store.main.collectionsAllLoaded() { |
||||
Patcher.applyAllWhenApplicable() |
||||
} |
||||
|
||||
} |
||||
|
||||
fileprivate func _fixMissingClubCreatorIfNecessary(_ clubsCollection: StoredCollection<Club>) { |
||||
do { |
||||
for club in clubsCollection { |
||||
if let userId = StoreCenter.main.userId, club.creator == nil { |
||||
club.creator = userId |
||||
self.userStorage.item()?.addClub(club) |
||||
try self.userStorage.update() |
||||
clubsCollection.writeChangeAndInsertOnServer(instance: club) |
||||
} |
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
fileprivate func _fixMissingEventCreatorIfNecessary(_ eventsCollection: StoredCollection<Event>) { |
||||
for event in eventsCollection { |
||||
if let userId = StoreCenter.main.userId, event.creator == nil { |
||||
event.creator = userId |
||||
do { |
||||
try event.insertOnServer() |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@objc func collectionDidUpdate(notification: Notification) { |
||||
self.objectWillChange.send() |
||||
} |
||||
|
||||
func disconnect() { |
||||
|
||||
Task { |
||||
if await StoreCenter.main.hasPendingAPICalls() { |
||||
// todo qu'est ce qu'on fait des API Call ? |
||||
} |
||||
|
||||
do { |
||||
let services = try StoreCenter.main.service() |
||||
try await services.logout() |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
DispatchQueue.main.async { |
||||
self._localDisconnect() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
func deleteAccount() { |
||||
|
||||
Task { |
||||
do { |
||||
let services = try StoreCenter.main.service() |
||||
try await services.deleteAccount() |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
DispatchQueue.main.async { |
||||
self._localDisconnect() |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
|
||||
fileprivate func _localDisconnect() { |
||||
|
||||
StoreCenter.main.collectionsCanSynchronize = false |
||||
|
||||
self.tournaments.reset() |
||||
self.clubs.reset() |
||||
self.courts.reset() |
||||
self.events.reset() |
||||
self.dateIntervals.reset() |
||||
self.userStorage.reset() |
||||
|
||||
for tournament in self.tournaments { |
||||
StoreCenter.main.destroyStore(identifier: tournament.id) |
||||
} |
||||
|
||||
Guard.main.disconnect() |
||||
|
||||
self.user = self._temporaryLocalUser.item ?? User.placeHolder() |
||||
self.user.clubs.removeAll() |
||||
|
||||
StoreCenter.main.disconnect() |
||||
} |
||||
|
||||
func copyToLocalServer(tournament: Tournament) { |
||||
|
||||
Task { |
||||
do { |
||||
|
||||
if let url = PListReader.readString(plist: "local", key: "local_server"), |
||||
let login = PListReader.readString(plist: "local", key: "username"), |
||||
let pass = PListReader.readString(plist: "local", key: "password") { |
||||
let service = Services(url: url) |
||||
let _: User = try await service.login(username: login, password: pass) |
||||
|
||||
tournament.event = nil |
||||
_ = try await service.post(tournament) |
||||
|
||||
for groupStage in tournament.groupStages() { |
||||
_ = try await service.post(groupStage) |
||||
} |
||||
for round in tournament.rounds() { |
||||
try await self._insertRoundAndChildren(round: round, service: service) |
||||
} |
||||
for teamRegistration in tournament.unsortedTeams() { |
||||
_ = try await service.post(teamRegistration) |
||||
for playerRegistration in teamRegistration.unsortedPlayers() { |
||||
_ = try await service.post(playerRegistration) |
||||
} |
||||
} |
||||
for groupStage in tournament.groupStages() { |
||||
for match in groupStage._matches() { |
||||
try await self._insertMatch(match: match, service: service) |
||||
} |
||||
} |
||||
for round in tournament.allRounds() { |
||||
for match in round._matches() { |
||||
try await self._insertMatch(match: match, service: service) |
||||
} |
||||
} |
||||
|
||||
} |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
fileprivate func _insertRoundAndChildren(round: Round, service: Services) async throws { |
||||
_ = try await service.post(round) |
||||
for loserRound in round.loserRounds() { |
||||
try await self._insertRoundAndChildren(round: loserRound, service: service) |
||||
} |
||||
} |
||||
|
||||
fileprivate func _insertMatch(match: Match, service: Services) async throws { |
||||
_ = try await service.post(match) |
||||
for teamScore in match.teamScores { |
||||
_ = try await service.post(teamScore) |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Convenience |
||||
|
||||
func runningMatches() -> [Match] { |
||||
let dateNow : Date = Date() |
||||
let lastTournaments = self.tournaments.filter { $0.isDeleted == false && $0.startDate <= dateNow }.sorted(by: \Tournament.startDate, order: .descending).prefix(10) |
||||
|
||||
var runningMatches: [Match] = [] |
||||
for tournament in lastTournaments { |
||||
let matches = tournament.tournamentStore.matches.filter { match in |
||||
match.confirmed && match.startDate != nil && match.endDate == nil } |
||||
runningMatches.append(contentsOf: matches) |
||||
} |
||||
return runningMatches |
||||
} |
||||
|
||||
} |
||||
@ -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,151 +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) |
||||
|
||||
if let creator = creator { |
||||
try container.encode(creator, forKey: ._creator) |
||||
} else { |
||||
try container.encodeNil(forKey: ._creator) |
||||
} |
||||
|
||||
if let club = club { |
||||
try container.encode(club, forKey: ._club) |
||||
} else { |
||||
try container.encodeNil(forKey: ._club) |
||||
} |
||||
|
||||
try container.encode(creationDate, forKey: ._creationDate) |
||||
|
||||
if let name = name { |
||||
try container.encode(name, forKey: ._name) |
||||
} else { |
||||
try container.encodeNil(forKey: ._name) |
||||
} |
||||
|
||||
if let tenupId = tenupId { |
||||
try container.encode(tenupId, forKey: ._tenupId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._tenupId) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -1,463 +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 matchFormat: MatchFormat { |
||||
get { |
||||
format ?? .defaultFormatForMatchType(.groupStage) |
||||
} |
||||
set { |
||||
format = newValue |
||||
} |
||||
} |
||||
|
||||
internal init(tournament: String, index: Int, size: Int, matchFormat: MatchFormat? = nil, startDate: Date? = nil, name: String? = nil) { |
||||
self.tournament = tournament |
||||
self.index = index |
||||
self.size = size |
||||
self.format = matchFormat |
||||
self.startDate = startDate |
||||
self.name = name |
||||
} |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
return TournamentStore.instance(tournamentId: self.tournament) |
||||
} |
||||
|
||||
// MARK: - Computed dependencies |
||||
|
||||
func _matches() -> [Match] { |
||||
return self.tournamentStore.matches.filter { $0.groupStage == self.id } |
||||
// Store.main.filter { $0.groupStage == self.id } |
||||
} |
||||
|
||||
func tournamentObject() -> Tournament? { |
||||
Store.main.findById(self.tournament) |
||||
} |
||||
|
||||
// MARK: - |
||||
|
||||
func teamAt(groupStagePosition: Int) -> TeamRegistration? { |
||||
teams().first(where: { $0.groupStagePosition == groupStagePosition }) |
||||
} |
||||
|
||||
func groupStageTitle(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
if let name { return name } |
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return "Poule \(index + 1)" |
||||
case .short: |
||||
return "#\(index + 1)" |
||||
} |
||||
} |
||||
|
||||
func isRunning() -> Bool { // at least a match has started |
||||
_matches().anySatisfy({ $0.isRunning() }) |
||||
} |
||||
|
||||
func hasStarted() -> Bool { // meaning at least one match is over |
||||
_matches().filter { $0.hasEnded() }.isEmpty == false |
||||
} |
||||
|
||||
func hasEnded() -> Bool { |
||||
guard teams().count == size else { return false } |
||||
let _matches = _matches() |
||||
if _matches.isEmpty { return false } |
||||
return _matches.anySatisfy { $0.hasEnded() == false } == false |
||||
} |
||||
|
||||
fileprivate func _createMatch(index: Int) -> Match { |
||||
let match: Match = Match(groupStage: self.id, |
||||
index: index, |
||||
matchFormat: self.matchFormat, |
||||
name: self.localizedMatchUpLabel(for: index)) |
||||
match.store = self.store |
||||
return match |
||||
} |
||||
|
||||
func buildMatches() { |
||||
_removeMatches() |
||||
|
||||
var matches = [Match]() |
||||
var teamScores = [TeamScore]() |
||||
|
||||
for i in 0..<_numberOfMatchesToBuild() { |
||||
let newMatch = self._createMatch(index: i) |
||||
// let newMatch = Match(groupStage: self.id, index: i, matchFormat: self.matchFormat, name: localizedMatchUpLabel(for: i)) |
||||
teamScores.append(contentsOf: newMatch.createTeamScores()) |
||||
matches.append(newMatch) |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func playedMatches() -> [Match] { |
||||
let ordered = _matches() |
||||
if ordered.isEmpty == false && ordered.count == _matchOrder().count { |
||||
return _matchOrder().map { |
||||
ordered[$0] |
||||
} |
||||
} else { |
||||
return ordered |
||||
} |
||||
} |
||||
|
||||
func updateGroupStageState() { |
||||
if hasEnded(), let tournament = tournamentObject() { |
||||
do { |
||||
let teams = teams(true) |
||||
for (index, team) in teams.enumerated() { |
||||
team.qualified = index < tournament.qualifiedPerGroupStage |
||||
if team.bracketPosition != nil && team.qualified == false { |
||||
tournamentObject()?.resetTeamScores(in: team.bracketPosition) |
||||
team.bracketPosition = nil |
||||
} |
||||
} |
||||
try self.tournamentStore.teamRegistrations.addOrUpdate(contentOfs: teams) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func scoreLabel(forGroupStagePosition groupStagePosition: Int, score: TeamGroupStageScore? = nil) -> (wins: String, losses: String, setsDifference: String?, gamesDifference: String?)? { |
||||
if let scoreData = (score ?? _score(forGroupStagePosition: groupStagePosition, nilIfEmpty: true)) { |
||||
let hideSetDifference = matchFormat.setsToWin == 1 |
||||
let setDifference = scoreData.setDifference.formatted(.number.sign(strategy: .always(includingZero: false))) |
||||
let gameDifference = scoreData.gameDifference.formatted(.number.sign(strategy: .always(includingZero: false))) |
||||
return (wins: scoreData.wins.formatted(), losses: scoreData.loses.formatted(), setsDifference: hideSetDifference ? nil : setDifference, gamesDifference: gameDifference) |
||||
// return "\(scoreData.wins)/\(scoreData.loses) " + differenceAsString |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func _score(forGroupStagePosition groupStagePosition: Int, nilIfEmpty: Bool = false) -> TeamGroupStageScore? { |
||||
guard let team = teamAt(groupStagePosition: groupStagePosition) else { return nil } |
||||
let matches = matches(forGroupStagePosition: groupStagePosition).filter({ $0.hasEnded() }) |
||||
if matches.isEmpty && nilIfEmpty { return nil } |
||||
let wins = matches.filter { $0.winningTeamId == team.id }.count |
||||
let loses = matches.filter { $0.losingTeamId == team.id }.count |
||||
let differences = matches.compactMap { $0.scoreDifference(groupStagePosition) } |
||||
let setDifference = differences.map { $0.set }.reduce(0,+) |
||||
let gameDifference = differences.map { $0.game }.reduce(0,+) |
||||
return (team, wins, loses, setDifference, gameDifference) |
||||
/* |
||||
• 2 points par rencontre gagnée |
||||
• 1 point par rencontre perdue |
||||
• -1 point en cas de rencontre perdue par disqualification (scores de 6/0 6/0 attribués aux trois matchs) |
||||
• -2 points en cas de rencontre perdu par WO (scores de 6/0 6/0 attribués aux trois matchs) |
||||
*/ |
||||
} |
||||
|
||||
func matches(forGroupStagePosition groupStagePosition: Int) -> [Match] { |
||||
let combos = Array((0..<size).combinations(ofCount: 2)) |
||||
var matchIndexes = [Int]() |
||||
for (index, combo) in combos.enumerated() { |
||||
if combo.contains(groupStagePosition) { //team is playing |
||||
matchIndexes.append(index) |
||||
} |
||||
} |
||||
return _matches().filter { matchIndexes.contains($0.index) } |
||||
} |
||||
|
||||
func initialStartDate(forTeam team: TeamRegistration) -> Date? { |
||||
guard let groupStagePosition = team.groupStagePosition else { return nil } |
||||
return matches(forGroupStagePosition: groupStagePosition).compactMap({ $0.startDate }).sorted().first ?? startDate |
||||
} |
||||
|
||||
func matchPlayed(by groupStagePosition: Int, againstPosition: Int) -> Match? { |
||||
if groupStagePosition == againstPosition { return nil } |
||||
let combos = Array((0..<size).combinations(ofCount: 2)) |
||||
var matchIndexes = [Int]() |
||||
for (index, combo) in combos.enumerated() { |
||||
if combo.contains(groupStagePosition) && combo.contains(againstPosition) { //teams are playing |
||||
matchIndexes.append(index) |
||||
} |
||||
} |
||||
return _matches().first(where: { matchIndexes.contains($0.index) }) |
||||
} |
||||
|
||||
func availableToStart(playedMatches: [Match], in runningMatches: [Match]) -> [Match] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func group stage availableToStart", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return playedMatches.filter({ $0.canBeStarted(inMatches: runningMatches) && $0.isRunning() == false }) |
||||
} |
||||
|
||||
func runningMatches(playedMatches: [Match]) -> [Match] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func group stage runningMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return playedMatches.filter({ $0.isRunning() }).sorted(by: \.computedStartDateForSorting) |
||||
} |
||||
|
||||
func readyMatches(playedMatches: [Match]) -> [Match] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func group stage readyMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return playedMatches.filter({ $0.isReady() && $0.isRunning() == false && $0.hasEnded() == false }) |
||||
} |
||||
|
||||
func finishedMatches(playedMatches: [Match]) -> [Match] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func group stage finishedMatches", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return playedMatches.filter({ $0.hasEnded() }).sorted(by: \.computedEndDateForSorting).reversed() |
||||
} |
||||
|
||||
private func _matchOrder() -> [Int] { |
||||
switch size { |
||||
case 3: |
||||
return [1, 2, 0] |
||||
case 4: |
||||
return [2, 3, 1, 4, 5, 0] |
||||
case 5: |
||||
return [5, 8, 0, 7, 3, 4, 2, 6, 1, 9] |
||||
// return [3, 5, 8, 2, 6, 7, 1, 9, 4, 0] |
||||
case 6: |
||||
return [1, 7, 13, 11, 3, 6, 10, 2, 8, 12, 5, 4, 9, 14, 0] |
||||
//return [4, 7, 9, 3, 6, 11, 2, 8, 10, 1, 13, 5, 12, 14, 0] |
||||
default: |
||||
return [] |
||||
} |
||||
} |
||||
|
||||
private func _matchUp(for matchIndex: Int) -> [Int] { |
||||
Array((0..<size).combinations(ofCount: 2))[safe: matchIndex] ?? [] |
||||
} |
||||
|
||||
func localizedMatchUpLabel(for matchIndex: Int) -> String { |
||||
let matchUp = _matchUp(for: matchIndex) |
||||
if let index = matchUp.first, let index2 = matchUp.last { |
||||
return "#\(index + 1) contre #\(index2 + 1)" |
||||
} else { |
||||
return "--" |
||||
} |
||||
} |
||||
|
||||
func team(teamPosition team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? { |
||||
let _teams = _teams(for: matchIndex) |
||||
switch team { |
||||
case .one: |
||||
return _teams.first ?? nil |
||||
case .two: |
||||
return _teams.last ?? nil |
||||
} |
||||
} |
||||
|
||||
private func _teams(for matchIndex: Int) -> [TeamRegistration?] { |
||||
let combinations = Array(0..<size).combinations(ofCount: 2).map {$0} |
||||
return combinations[safe: matchIndex]?.map { teamAt(groupStagePosition: $0) } ?? [] |
||||
} |
||||
|
||||
private func _removeMatches() { |
||||
do { |
||||
try self.tournamentStore.matches.delete(contentOfs: _matches()) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
private func _numberOfMatchesToBuild() -> Int { |
||||
(size * (size - 1)) / 2 |
||||
} |
||||
|
||||
func unsortedPlayers() -> [PlayerRegistration] { |
||||
unsortedTeams().flatMap({ $0.unsortedPlayers() }) |
||||
} |
||||
|
||||
fileprivate typealias TeamScoreAreInIncreasingOrder = (TeamGroupStageScore, TeamGroupStageScore) -> Bool |
||||
|
||||
typealias TeamGroupStageScore = (team: TeamRegistration, wins: Int, loses: Int, setDifference: Int, gameDifference: Int) |
||||
|
||||
fileprivate func _headToHead(_ teamPosition: TeamRegistration, _ otherTeam: TeamRegistration) -> Bool { |
||||
let indexes = [teamPosition, otherTeam].compactMap({ $0.groupStagePosition }).sorted() |
||||
let combos = Array((0..<size).combinations(ofCount: 2)) |
||||
if let matchIndex = combos.firstIndex(of: indexes), let match = _matches().first(where: { $0.index == matchIndex }) { |
||||
return teamPosition.id == match.losingTeamId |
||||
} else { |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func unsortedTeams() -> [TeamRegistration] { |
||||
return self.tournamentStore.teamRegistrations.filter { $0.groupStage == self.id && $0.groupStagePosition != nil } |
||||
} |
||||
|
||||
func teams(_ sortedByScore: Bool = false, scores: [TeamGroupStageScore]? = nil) -> [TeamRegistration] { |
||||
if sortedByScore { |
||||
return unsortedTeams().compactMap({ team in |
||||
scores?.first(where: { $0.team.id == team.id }) ?? _score(forGroupStagePosition: team.groupStagePosition!) |
||||
}).sorted { (lhs, rhs) in |
||||
let predicates: [TeamScoreAreInIncreasingOrder] = [ |
||||
{ $0.wins < $1.wins }, |
||||
{ $0.setDifference < $1.setDifference }, |
||||
{ $0.gameDifference < $1.gameDifference}, |
||||
{ self._headToHead($0.team, $1.team) }, |
||||
{ $0.team.groupStagePosition! > $1.team.groupStagePosition! } |
||||
] |
||||
|
||||
for predicate in predicates { |
||||
if !predicate(lhs, rhs) && !predicate(rhs, lhs) { |
||||
continue |
||||
} |
||||
|
||||
return predicate(lhs, rhs) |
||||
} |
||||
|
||||
return false |
||||
}.map({ $0.team }).reversed() |
||||
} else { |
||||
return unsortedTeams().sorted(by: \TeamRegistration.groupStagePosition!) |
||||
} |
||||
} |
||||
|
||||
func updateMatchFormat(_ updatedMatchFormat: MatchFormat) { |
||||
self.matchFormat = updatedMatchFormat |
||||
self.updateAllMatchesFormat() |
||||
} |
||||
|
||||
func updateAllMatchesFormat() { |
||||
let playedMatches = playedMatches() |
||||
playedMatches.forEach { match in |
||||
match.matchFormat = matchFormat |
||||
} |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: playedMatches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func pasteData() -> String { |
||||
var data: [String] = [] |
||||
data.append(self.groupStageTitle()) |
||||
teams().forEach { team in |
||||
data.append(team.teamLabelRanked(displayRank: true, displayTeamName: true)) |
||||
} |
||||
|
||||
return data.joined(separator: "\n") |
||||
} |
||||
|
||||
override func deleteDependencies() throws { |
||||
let matches = self._matches() |
||||
for match in matches { |
||||
try match.deleteDependencies() |
||||
} |
||||
self.tournamentStore.matches.deleteDependencies(matches) |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
try container.encode(tournament, forKey: ._tournament) |
||||
try container.encode(index, forKey: ._index) |
||||
try container.encode(size, forKey: ._size) |
||||
|
||||
if let format = format { |
||||
try container.encode(format, forKey: ._format) |
||||
} else { |
||||
try container.encodeNil(forKey: ._format) |
||||
} |
||||
|
||||
if let startDate = startDate { |
||||
try container.encode(startDate, forKey: ._startDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._startDate) |
||||
} |
||||
|
||||
if let name = name { |
||||
try container.encode(name, forKey: ._name) |
||||
} else { |
||||
try container.encodeNil(forKey: ._name) |
||||
} |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.groupStages.writeChangeAndInsertOnServer(instance: self) |
||||
for match in self._matches() { |
||||
match.insertOnServer() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension GroupStage { |
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _tournament = "tournament" |
||||
case _index = "index" |
||||
case _size = "size" |
||||
case _format = "format" |
||||
case _startDate = "startDate" |
||||
case _name = "name" |
||||
} |
||||
} |
||||
|
||||
extension GroupStage: Selectable { |
||||
func selectionLabel(index: Int) -> String { |
||||
groupStageTitle() |
||||
} |
||||
|
||||
func badgeValue() -> Int? { |
||||
return runningMatches(playedMatches: _matches()).count |
||||
} |
||||
|
||||
func badgeValueColor() -> Color? { |
||||
return nil |
||||
} |
||||
|
||||
func badgeImage() -> Badge? { |
||||
if teams().count < size { |
||||
return .xmark |
||||
} else { |
||||
return hasEnded() ? .checkmark : nil |
||||
} |
||||
} |
||||
} |
||||
@ -1,959 +0,0 @@ |
||||
// |
||||
// Match.swift |
||||
// Padel Tournament |
||||
// |
||||
// Created by razmig on 10/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
@Observable |
||||
final class Match: ModelObject, Storable { |
||||
static func resourceName() -> String { "matches" } |
||||
static func tokenExemptedMethods() -> [HTTPMethod] { return [] } |
||||
static func filterByStoreIdentifier() -> Bool { return true } |
||||
static var relationshipNames: [String] = ["round", "groupStage"] |
||||
|
||||
static func setServerTitle(upperRound: Round, matchIndex: Int) -> String { |
||||
if upperRound.index == 0 { return upperRound.roundTitle() } |
||||
return upperRound.roundTitle() + " #" + (matchIndex + 1).formatted() |
||||
} |
||||
|
||||
var byeState: Bool = false |
||||
|
||||
var id: String = Store.randomId() |
||||
var round: String? |
||||
var groupStage: String? |
||||
var startDate: Date? |
||||
var endDate: Date? |
||||
var index: Int |
||||
private var format: MatchFormat? |
||||
//var court: String? |
||||
var servingTeamId: String? |
||||
var winningTeamId: String? |
||||
var losingTeamId: String? |
||||
//var broadcasted: Bool |
||||
var name: String? |
||||
//var order: Int |
||||
var disabled: Bool = false |
||||
private(set) var courtIndex: Int? |
||||
var confirmed: Bool = false |
||||
|
||||
init(round: String? = nil, groupStage: String? = nil, startDate: Date? = nil, endDate: Date? = nil, index: Int, matchFormat: MatchFormat? = nil, servingTeamId: String? = nil, winningTeamId: String? = nil, losingTeamId: String? = nil, name: String? = nil, disabled: Bool = false, courtIndex: Int? = nil, confirmed: Bool = false) { |
||||
self.round = round |
||||
self.groupStage = groupStage |
||||
self.startDate = startDate |
||||
self.endDate = endDate |
||||
self.index = index |
||||
self.format = matchFormat |
||||
//self.court = court |
||||
self.servingTeamId = servingTeamId |
||||
self.winningTeamId = winningTeamId |
||||
self.losingTeamId = losingTeamId |
||||
self.disabled = disabled |
||||
self.name = name |
||||
self.courtIndex = courtIndex |
||||
self.confirmed = confirmed |
||||
// self.broadcasted = broadcasted |
||||
// self.order = order |
||||
} |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
if let store = self.store as? TournamentStore { |
||||
return store |
||||
} |
||||
fatalError("missing store for \(String(describing: type(of: self)))") |
||||
} |
||||
|
||||
var courtIndexForSorting: Int { |
||||
courtIndex ?? Int.max |
||||
} |
||||
|
||||
// MARK: - Computed dependencies |
||||
|
||||
var teamScores: [TeamScore] { |
||||
return self.tournamentStore.teamScores.filter { $0.match == self.id } |
||||
} |
||||
|
||||
// MARK: - |
||||
|
||||
override func deleteDependencies() throws { |
||||
guard let tournament = self.currentTournament() else { |
||||
return |
||||
} |
||||
let teamScores = self.teamScores |
||||
for teamScore in teamScores { |
||||
try teamScore.deleteDependencies() |
||||
} |
||||
tournament.tournamentStore.teamScores.deleteDependencies(teamScores) |
||||
} |
||||
|
||||
func indexInRound(in matches: [Match]? = nil) -> Int { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func indexInRound(in", matches?.count, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
if groupStage != nil { |
||||
return index |
||||
} else if let index = (matches ?? roundObject?.playedMatches().sorted(by: \.index))?.firstIndex(where: { $0.id == id }) { |
||||
return index |
||||
} |
||||
return RoundRule.matchIndexWithinRound(fromMatchIndex: index) |
||||
} |
||||
|
||||
func matchWarningSubject() -> String { |
||||
[roundTitle(), matchTitle(.short)].compacted().joined(separator: " ") |
||||
} |
||||
|
||||
func matchWarningMessage() -> String { |
||||
[roundTitle(), matchTitle(.short), startDate?.localizedDate(), courtName()].compacted().joined(separator: "\n") |
||||
} |
||||
|
||||
func matchTitle(_ displayStyle: DisplayStyle = .wide, inMatches matches: [Match]? = nil) -> String { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func matchTitle", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
if let groupStageObject { |
||||
return groupStageObject.localizedMatchUpLabel(for: index) |
||||
} |
||||
|
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return "Match \(indexInRound(in: matches) + 1)" |
||||
case .short: |
||||
return "#\(indexInRound(in: matches) + 1)" |
||||
} |
||||
} |
||||
|
||||
func isSeedLocked(atTeamPosition teamPosition: TeamPosition) -> Bool { |
||||
return previousMatch(teamPosition)?.disabled == true |
||||
} |
||||
|
||||
func unlockSeedPosition(atTeamPosition teamPosition: TeamPosition) { |
||||
previousMatch(teamPosition)?.enableMatch() |
||||
} |
||||
|
||||
@discardableResult |
||||
func lockAndGetSeedPosition(atTeamPosition slot: TeamPosition?, opposingSeeding: Bool = false) -> Int { |
||||
let matchIndex = index |
||||
var teamPosition : TeamPosition { |
||||
if let slot { |
||||
return slot |
||||
} else { |
||||
let seedRound = RoundRule.roundIndex(fromMatchIndex: matchIndex) |
||||
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: seedRound) |
||||
let isUpper = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) < (numberOfMatches / 2) |
||||
var teamPosition = slot ?? (isUpper ? .one : .two) |
||||
if opposingSeeding { |
||||
teamPosition = slot ?? (isUpper ? .two : .one) |
||||
} |
||||
return teamPosition |
||||
} |
||||
} |
||||
previousMatch(teamPosition)?.disableMatch() |
||||
return matchIndex * 2 + teamPosition.rawValue |
||||
} |
||||
|
||||
func isSeededBy(team: TeamRegistration, inTeamPosition teamPosition: TeamPosition) -> Bool { |
||||
guard let roundObject, roundObject.isUpperBracket() else { return false } |
||||
guard let bracketPosition = team.bracketPosition else { return false } |
||||
return index * 2 + teamPosition.rawValue == bracketPosition |
||||
} |
||||
|
||||
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? { |
||||
let minutesToAdd = Double(matchFormat.getEstimatedDuration(additionalEstimationDuration)) |
||||
return startDate?.addingTimeInterval(minutesToAdd * 60.0) |
||||
} |
||||
|
||||
func winner() -> TeamRegistration? { |
||||
guard let winningTeamId else { return nil } |
||||
return self.tournamentStore.teamRegistrations.findById(winningTeamId) |
||||
} |
||||
|
||||
func localizedStartDate() -> String { |
||||
if let startDate { |
||||
return startDate.formatted(date: .abbreviated, time: .shortened) |
||||
} else { |
||||
return "" |
||||
} |
||||
} |
||||
|
||||
func scoreLabel() -> String { |
||||
if hasWalkoutTeam() == true { |
||||
return "WO" |
||||
} |
||||
let scoreOne = teamScore(.one)?.score?.components(separatedBy: ",") ?? [] |
||||
let scoreTwo = teamScore(.two)?.score?.components(separatedBy: ",") ?? [] |
||||
let tuples = zip(scoreOne, scoreTwo).map { ($0, $1) } |
||||
let scores = tuples.map { $0 + "/" + $1 }.joined(separator: " ") |
||||
return scores |
||||
} |
||||
|
||||
func cleanScheduleAndSave(_ targetStartDate: Date? = nil) { |
||||
startDate = targetStartDate |
||||
confirmed = targetStartDate == nil ? false : true |
||||
endDate = nil |
||||
followingMatch()?.cleanScheduleAndSave(nil) |
||||
_loserMatch()?.cleanScheduleAndSave(nil) |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(instance: self) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func resetMatch() { |
||||
losingTeamId = nil |
||||
winningTeamId = nil |
||||
endDate = nil |
||||
removeCourt() |
||||
servingTeamId = nil |
||||
} |
||||
|
||||
func resetScores() { |
||||
teamScores.forEach({ $0.score = nil }) |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teamScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func teamWillBeWalkOut(_ team: TeamRegistration) { |
||||
resetMatch() |
||||
let existingTeamScore = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team) |
||||
existingTeamScore.walkOut = 1 |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(instance: existingTeamScore) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func luckyLosers() -> [TeamRegistration] { |
||||
return roundObject?.previousRound()?.losers() ?? [] |
||||
} |
||||
|
||||
func isWalkOutSpot(_ teamPosition: TeamPosition) -> Bool { |
||||
return teamScore(teamPosition)?.walkOut == 1 |
||||
} |
||||
|
||||
func setLuckyLoser(team: TeamRegistration, teamPosition: TeamPosition) { |
||||
resetMatch() |
||||
|
||||
let matchIndex = index |
||||
let position = matchIndex * 2 + teamPosition.rawValue |
||||
|
||||
let previousScores = teamScores.filter({ $0.luckyLoser == position }) |
||||
do { |
||||
try self.tournamentStore.teamScores.delete(contentOfs: previousScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
let teamScoreLuckyLoser = teamScore(ofTeam: team) ?? TeamScore(match: id, team: team) |
||||
teamScoreLuckyLoser.luckyLoser = position |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(instance: teamScoreLuckyLoser) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func disableMatch() { |
||||
_toggleMatchDisableState(true) |
||||
} |
||||
|
||||
func enableMatch() { |
||||
_toggleMatchDisableState(false) |
||||
} |
||||
|
||||
private func _loserMatch() -> Match? { |
||||
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: index) |
||||
return roundObject?.loserRounds().first?.getMatch(atMatchIndexInRound: indexInRound / 2) |
||||
} |
||||
|
||||
func _toggleLoserMatchDisableState(_ state: Bool) { |
||||
guard let loserMatch = _loserMatch() else { return } |
||||
guard let next = _otherMatch() else { return } |
||||
loserMatch.byeState = true |
||||
if next.disabled { |
||||
loserMatch.byeState = false |
||||
} |
||||
loserMatch._toggleMatchDisableState(state, forward: true) |
||||
} |
||||
|
||||
fileprivate func _otherMatch() -> Match? { |
||||
guard let round else { return nil } |
||||
guard index > 0 else { return nil } |
||||
let nextIndex = (index - 1) / 2 |
||||
let topMatchIndex = (nextIndex * 2) + 1 |
||||
let bottomMatchIndex = (nextIndex + 1) * 2 |
||||
let isTopMatch = topMatchIndex + 1 == index |
||||
let lookingForIndex = isTopMatch ? topMatchIndex : bottomMatchIndex |
||||
|
||||
return self.tournamentStore.matches.first(where: { $0.round == round && $0.index == lookingForIndex }) |
||||
|
||||
} |
||||
|
||||
private func _forwardMatch(inRound round: Round) -> Match? { |
||||
guard let roundObjectNextRound = round.nextRound() else { return nil } |
||||
let nextIndex = (index - 1) / 2 |
||||
return self.tournamentStore.matches.first(where: { $0.round == roundObjectNextRound.id && $0.index == nextIndex }) |
||||
} |
||||
|
||||
func _toggleForwardMatchDisableState(_ state: Bool) { |
||||
guard let roundObject else { return } |
||||
guard roundObject.parent != nil else { return } |
||||
guard let forwardMatch = _forwardMatch(inRound: roundObject) else { return } |
||||
guard let next = _otherMatch() else { return } |
||||
if next.disabled && byeState == false && next.byeState == false { |
||||
forwardMatch.byeState = false |
||||
forwardMatch._toggleMatchDisableState(state, forward: true) |
||||
} else if byeState && next.byeState { |
||||
print("don't disable forward match") |
||||
forwardMatch.byeState = false |
||||
forwardMatch._toggleMatchDisableState(false, forward: true) |
||||
} else { |
||||
forwardMatch.byeState = true |
||||
forwardMatch._toggleMatchDisableState(state, forward: true) |
||||
} |
||||
|
||||
// if next.disabled == false { |
||||
// forwardMatch.byeState = state |
||||
// } |
||||
|
||||
|
||||
|
||||
// |
||||
// if next.disabled == state { |
||||
// if next.byeState != byeState { |
||||
// //forwardMatch.byeState = state |
||||
// forwardMatch._toggleMatchDisableState(state) |
||||
// } else { |
||||
// forwardMatch._toggleByeState(state) |
||||
// } |
||||
// } else { |
||||
// } |
||||
// forwardMatch._toggleByeState(state) |
||||
} |
||||
|
||||
func isSeededBy(team: TeamRegistration) -> Bool { |
||||
isSeededBy(team: team, inTeamPosition: .one) || isSeededBy(team: team, inTeamPosition: .two) |
||||
} |
||||
|
||||
func isSeeded() -> Bool { |
||||
return isSeededAt(.one) || isSeededAt(.two) |
||||
} |
||||
|
||||
func isSeededAt(_ teamPosition: TeamPosition) -> Bool { |
||||
if let team = team(teamPosition) { |
||||
return isSeededBy(team: team) |
||||
} |
||||
return false |
||||
} |
||||
|
||||
func _toggleMatchDisableState(_ state: Bool, forward: Bool = false) { |
||||
//if disabled == state { return } |
||||
disabled = state |
||||
if state == true { |
||||
let teams = teams() |
||||
for team in teams { |
||||
if isSeededBy(team: team) { |
||||
team.bracketPosition = nil |
||||
do { |
||||
try self.tournamentStore.teamRegistrations.addOrUpdate(instance: team) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
//byeState = false |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(instance: self) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
_toggleLoserMatchDisableState(state) |
||||
if forward { |
||||
_toggleForwardMatchDisableState(state) |
||||
} else { |
||||
topPreviousRoundMatch()?._toggleMatchDisableState(state) |
||||
bottomPreviousRoundMatch()?._toggleMatchDisableState(state) |
||||
} |
||||
} |
||||
|
||||
func next() -> Match? { |
||||
let matches: [Match] = self.tournamentStore.matches.filter { $0.round == round && $0.index > index } |
||||
return matches.sorted(by: \.index).first |
||||
} |
||||
|
||||
func followingMatch() -> Match? { |
||||
guard let nextRoundId = roundObject?.nextRound()?.id else { return nil } |
||||
return getFollowingMatch(fromNextRoundId: nextRoundId) |
||||
} |
||||
|
||||
func getFollowingMatch(fromNextRoundId nextRoundId: String) -> Match? { |
||||
return self.tournamentStore.matches.first(where: { $0.round == nextRoundId && $0.index == (index - 1) / 2 }) |
||||
} |
||||
|
||||
func getDuration() -> Int { |
||||
if let tournament = currentTournament() { |
||||
matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) |
||||
} else { |
||||
matchFormat.getEstimatedDuration() |
||||
} |
||||
} |
||||
|
||||
func roundTitle() -> String? { |
||||
if groupStage != nil { return groupStageObject?.groupStageTitle() } |
||||
else if let roundObject { return roundObject.roundTitle() } |
||||
else { return nil } |
||||
} |
||||
|
||||
func topPreviousRoundMatchIndex() -> Int { |
||||
return index * 2 + 1 |
||||
} |
||||
|
||||
func bottomPreviousRoundMatchIndex() -> Int { |
||||
return (index + 1) * 2 |
||||
} |
||||
|
||||
func topPreviousRoundMatch() -> Match? { |
||||
guard let roundObject else { return nil } |
||||
let topPreviousRoundMatchIndex = topPreviousRoundMatchIndex() |
||||
let roundObjectPreviousRoundId = roundObject.previousRound()?.id |
||||
return self.tournamentStore.matches.first(where: { match in |
||||
match.round != nil && match.round == roundObjectPreviousRoundId && match.index == topPreviousRoundMatchIndex |
||||
}) |
||||
} |
||||
|
||||
func bottomPreviousRoundMatch() -> Match? { |
||||
guard let roundObject else { return nil } |
||||
let bottomPreviousRoundMatchIndex = bottomPreviousRoundMatchIndex() |
||||
let roundObjectPreviousRoundId = roundObject.previousRound()?.id |
||||
return self.tournamentStore.matches.first(where: { match in |
||||
match.round != nil && match.round == roundObjectPreviousRoundId && match.index == bottomPreviousRoundMatchIndex |
||||
}) |
||||
} |
||||
|
||||
func previousMatch(_ teamPosition: TeamPosition) -> Match? { |
||||
if teamPosition == .one { |
||||
return topPreviousRoundMatch() |
||||
} else { |
||||
return bottomPreviousRoundMatch() |
||||
} |
||||
} |
||||
|
||||
var computedOrder: Int { |
||||
guard let roundObject else { return index } |
||||
return roundObject.isLoserBracket() ? roundObject.index * 100 + indexInRound() : roundObject.index * 1000 + indexInRound() |
||||
} |
||||
|
||||
func previousMatches() -> [Match] { |
||||
guard let roundObject else { return [] } |
||||
let roundObjectPreviousRoundId = roundObject.previousRound()?.id |
||||
return self.tournamentStore.matches.filter { match in |
||||
match.round == roundObjectPreviousRoundId && (match.index == topPreviousRoundMatchIndex() || match.index == bottomPreviousRoundMatchIndex()) |
||||
}.sorted(by: \.index) |
||||
} |
||||
|
||||
var matchFormat: MatchFormat { |
||||
get { |
||||
format ?? .defaultFormatForMatchType(.groupStage) |
||||
} |
||||
set { |
||||
format = newValue |
||||
} |
||||
} |
||||
|
||||
func setWalkOut(_ teamPosition: TeamPosition) { |
||||
let teamScoreWalkout = teamScore(teamPosition) ?? TeamScore(match: id, team: team(teamPosition)) |
||||
teamScoreWalkout.walkOut = 0 |
||||
let teamScoreWinning = teamScore(teamPosition.otherTeam) ?? TeamScore(match: id, team: team(teamPosition.otherTeam)) |
||||
teamScoreWinning.walkOut = nil |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreWalkout, teamScoreWinning]) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
if endDate == nil { |
||||
endDate = Date() |
||||
} |
||||
|
||||
winningTeamId = teamScoreWinning.teamRegistration |
||||
losingTeamId = teamScoreWalkout.teamRegistration |
||||
groupStageObject?.updateGroupStageState() |
||||
roundObject?.updateTournamentState() |
||||
updateFollowingMatchTeamScore() |
||||
} |
||||
|
||||
func setScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { |
||||
updateScore(fromMatchDescriptor: matchDescriptor) |
||||
if endDate == nil { |
||||
endDate = Date() |
||||
} |
||||
if startDate == nil { |
||||
startDate = endDate?.addingTimeInterval(Double(-getDuration()*60)) |
||||
} |
||||
|
||||
let teamOne = team(matchDescriptor.winner) |
||||
let teamTwo = team(matchDescriptor.winner.otherTeam) |
||||
|
||||
teamOne?.hasArrived() |
||||
teamTwo?.hasArrived() |
||||
|
||||
winningTeamId = teamOne?.id |
||||
losingTeamId = teamTwo?.id |
||||
|
||||
confirmed = true |
||||
|
||||
groupStageObject?.updateGroupStageState() |
||||
roundObject?.updateTournamentState() |
||||
updateFollowingMatchTeamScore() |
||||
} |
||||
|
||||
func updateScore(fromMatchDescriptor matchDescriptor: MatchDescriptor) { |
||||
let teamScoreOne = teamScore(.one) ?? TeamScore(match: id, team: team(.one)) |
||||
teamScoreOne.score = matchDescriptor.teamOneScores.joined(separator: ",") |
||||
let teamScoreTwo = teamScore(.two) ?? TeamScore(match: id, team: team(.two)) |
||||
teamScoreTwo.score = matchDescriptor.teamTwoScores.joined(separator: ",") |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: [teamScoreOne, teamScoreTwo]) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
matchFormat = matchDescriptor.matchFormat |
||||
} |
||||
|
||||
func updateFollowingMatchTeamScore() { |
||||
followingMatch()?.updateTeamScores() |
||||
_loserMatch()?.updateTeamScores() |
||||
} |
||||
|
||||
func resetTeamScores(outsideOf newTeamScores: [TeamScore]) { |
||||
let ids = newTeamScores.map { $0.id } |
||||
let teamScores = teamScores.filter({ ids.contains($0.id) == false }) |
||||
if teamScores.isEmpty == false { |
||||
do { |
||||
try self.tournamentStore.teamScores.delete(contentOfs: teamScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
followingMatch()?.resetTeamScores(outsideOf: []) |
||||
_loserMatch()?.resetTeamScores(outsideOf: []) |
||||
} |
||||
} |
||||
|
||||
func createTeamScores() -> [TeamScore] { |
||||
let teamOne = team(.one) |
||||
let teamTwo = team(.two) |
||||
let teams = [teamOne, teamTwo].compactMap({ $0 }).map { TeamScore(match: id, team: $0) } |
||||
return teams |
||||
} |
||||
|
||||
func getOrCreateTeamScores() -> [TeamScore] { |
||||
let teamOne = team(.one) |
||||
let teamTwo = team(.two) |
||||
let teams = [teamOne, teamTwo].compactMap({ $0 }).map { teamScore(ofTeam: $0) ?? TeamScore(match: id, team: $0) } |
||||
return teams |
||||
} |
||||
|
||||
func updateTeamScores() { |
||||
let teams = getOrCreateTeamScores() |
||||
do { |
||||
try self.tournamentStore.teamScores.addOrUpdate(contentOfs: teams) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
resetTeamScores(outsideOf: teams) |
||||
if teams.isEmpty == false { |
||||
updateFollowingMatchTeamScore() |
||||
} |
||||
} |
||||
|
||||
func validateMatch(fromStartDate: Date, toEndDate: Date, fieldSetup: MatchFieldSetup) { |
||||
if hasEnded() == false { |
||||
startDate = fromStartDate |
||||
|
||||
switch fieldSetup { |
||||
case .fullRandom: |
||||
if let _courtIndex = allCourts().randomElement() { |
||||
setCourt(_courtIndex) |
||||
} |
||||
case .random: |
||||
if let _courtIndex = availableCourts().randomElement() { |
||||
setCourt(_courtIndex) |
||||
} |
||||
case .field(let _courtIndex): |
||||
setCourt(_courtIndex) |
||||
} |
||||
|
||||
} else { |
||||
startDate = fromStartDate |
||||
endDate = toEndDate |
||||
} |
||||
|
||||
confirmed = true |
||||
} |
||||
|
||||
func courtName() -> String? { |
||||
guard let courtIndex else { return nil } |
||||
if let courtName = currentTournament()?.courtName(atIndex: courtIndex) { |
||||
return courtName |
||||
} else { |
||||
return Court.courtIndexedTitle(atIndex: courtIndex) |
||||
} |
||||
} |
||||
|
||||
func courtCount() -> Int { |
||||
return currentTournament()?.courtCount ?? 1 |
||||
} |
||||
|
||||
func courtIsAvailable(_ courtIndex: Int) -> Bool { |
||||
let courtUsed = currentTournament()?.courtUsed() ?? [] |
||||
return courtUsed.contains(courtIndex) == false |
||||
} |
||||
|
||||
func courtIsPreferred(_ courtIndex: Int) -> Bool { |
||||
return false |
||||
} |
||||
|
||||
func allCourts() -> [Int] { |
||||
let availableCourts = Array(0..<courtCount()) |
||||
return availableCourts |
||||
} |
||||
|
||||
func availableCourts() -> [Int] { |
||||
let courtUsed = currentTournament()?.courtUsed() ?? [] |
||||
return Array(Set(allCourts().map { $0 }).subtracting(Set(courtUsed))) |
||||
} |
||||
|
||||
func removeCourt() { |
||||
courtIndex = nil |
||||
} |
||||
|
||||
func setCourt(_ courtIndex: Int) { |
||||
self.courtIndex = courtIndex |
||||
} |
||||
|
||||
func canBeStarted(inMatches matches: [Match]) -> Bool { |
||||
let teams = teamScores |
||||
guard teams.count == 2 else { return false } |
||||
guard hasEnded() == false else { return false } |
||||
guard hasStarted() == false else { return false } |
||||
return teams.compactMap({ $0.team }).allSatisfy({ $0.canPlay() && isTeamPlaying($0, inMatches: matches) == false }) |
||||
} |
||||
|
||||
func isTeamPlaying(_ team: TeamRegistration, inMatches matches: [Match]) -> Bool { |
||||
return matches.filter({ $0.teamScores.compactMap { $0.teamRegistration }.contains(team.id) }).isEmpty == false |
||||
} |
||||
|
||||
var computedStartDateForSorting: Date { |
||||
return startDate ?? .distantFuture |
||||
} |
||||
|
||||
var computedEndDateForSorting: Date { |
||||
return endDate ?? .distantFuture |
||||
} |
||||
|
||||
func hasSpaceLeft() -> Bool { |
||||
return teamScores.count < 2 |
||||
} |
||||
|
||||
func isReady() -> Bool { |
||||
return teamScores.count >= 2 |
||||
// teams().count == 2 |
||||
} |
||||
|
||||
func isEmpty() -> Bool { |
||||
return teamScores.isEmpty |
||||
// teams().isEmpty |
||||
} |
||||
|
||||
func hasEnded() -> Bool { |
||||
return endDate != nil |
||||
} |
||||
|
||||
func isGroupStage() -> Bool { |
||||
return groupStage != nil |
||||
} |
||||
|
||||
func isBracket() -> Bool { |
||||
return round != nil |
||||
} |
||||
|
||||
func walkoutTeam() -> [TeamRegistration] { |
||||
//walkout 0 means real walkout, walkout 1 means lucky loser situation |
||||
return scores().filter({ $0.walkOut == 0 }).compactMap { $0.team } |
||||
} |
||||
|
||||
func hasWalkoutTeam() -> Bool { |
||||
return walkoutTeam().isEmpty == false |
||||
} |
||||
|
||||
func currentTournament() -> Tournament? { |
||||
return groupStageObject?.tournamentObject() ?? roundObject?.tournamentObject() |
||||
} |
||||
|
||||
func tournamentId() -> String? { |
||||
return groupStageObject?.tournament ?? roundObject?.tournament |
||||
} |
||||
|
||||
func scores() -> [TeamScore] { |
||||
return self.tournamentStore.teamScores.filter { $0.match == id } |
||||
} |
||||
|
||||
func teams() -> [TeamRegistration] { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func teams()", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
if groupStage != nil { |
||||
return [groupStageProjectedTeam(.one), groupStageProjectedTeam(.two)].compactMap { $0 } |
||||
} |
||||
guard let roundObject else { return [] } |
||||
let previousRound = roundObject.previousRound() |
||||
return [roundObject.roundProjectedTeam(.one, inMatch: self, previousRound: previousRound), roundObject.roundProjectedTeam(.two, inMatch: self, previousRound: previousRound)].compactMap { $0 } |
||||
|
||||
// return [roundProjectedTeam(.one), roundProjectedTeam(.two)].compactMap { $0 } |
||||
} |
||||
|
||||
func scoreDifference(_ teamPosition: Int) -> (set: Int, game: Int)? { |
||||
guard let teamScoreTeam = teamScore(.one), let teamScoreOtherTeam = teamScore(.two) else { return nil } |
||||
var reverseValue = 1 |
||||
if teamPosition == team(.two)?.groupStagePosition { |
||||
reverseValue = -1 |
||||
} |
||||
let endedSetsOne = teamScoreTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreTeam.isWalkOut()) |
||||
let endedSetsTwo = teamScoreOtherTeam.score?.components(separatedBy: ",").compactMap({ Int($0) }) ?? matchFormat.defaultWalkOutScore(teamScoreOtherTeam.isWalkOut()) |
||||
var setDifference : Int = 0 |
||||
let zip = zip(endedSetsOne, endedSetsTwo) |
||||
if matchFormat.setsToWin == 1 { |
||||
setDifference = endedSetsOne[0] - endedSetsTwo[0] |
||||
} else { |
||||
setDifference = zip.filter { $0 > $1 }.count - zip.filter { $1 > $0 }.count |
||||
} |
||||
let gameDifference = zip.map { ($0, $1) }.map { $0.0 - $0.1 }.reduce(0,+) |
||||
return (setDifference * reverseValue, gameDifference * reverseValue) |
||||
} |
||||
|
||||
func groupStageProjectedTeam(_ team: TeamPosition) -> TeamRegistration? { |
||||
guard let groupStageObject else { return nil } |
||||
return groupStageObject.team(teamPosition: team, inMatchIndex: index) |
||||
} |
||||
|
||||
func roundProjectedTeam(_ team: TeamPosition) -> TeamRegistration? { |
||||
guard let roundObject else { return nil } |
||||
let previousRound = roundObject.previousRound() |
||||
return roundObject.roundProjectedTeam(team, inMatch: self, previousRound: previousRound) |
||||
} |
||||
|
||||
func teamWon(_ team: TeamRegistration?) -> Bool { |
||||
guard let winningTeamId else { return false } |
||||
return winningTeamId == team?.id |
||||
} |
||||
|
||||
func teamWon(atPosition teamPosition: TeamPosition) -> Bool { |
||||
guard let winningTeamId else { return false } |
||||
return winningTeamId == team(teamPosition)?.id |
||||
} |
||||
|
||||
func team(_ team: TeamPosition) -> TeamRegistration? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func match get team", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
if groupStage != nil { |
||||
return groupStageProjectedTeam(team) |
||||
} else { |
||||
return roundProjectedTeam(team) |
||||
} |
||||
} |
||||
|
||||
func teamNames(_ team: TeamRegistration?) -> [String]? { |
||||
return team?.players().map { $0.playerLabel() } |
||||
} |
||||
|
||||
func teamWalkOut(_ team: TeamRegistration?) -> Bool { |
||||
return teamScore(ofTeam: team)?.isWalkOut() == true |
||||
} |
||||
|
||||
func teamScore(_ team: TeamPosition) -> TeamScore? { |
||||
return teamScore(ofTeam: self.team(team)) |
||||
} |
||||
|
||||
func teamScore(ofTeam team: TeamRegistration?) -> TeamScore? { |
||||
return scores().first(where: { $0.teamRegistration == team?.id }) |
||||
} |
||||
|
||||
func isRunning() -> Bool { // at least a match has started |
||||
return confirmed && hasStarted() && hasEnded() == false |
||||
} |
||||
|
||||
func hasStarted() -> Bool { // meaning at least one match is over |
||||
if let startDate { |
||||
return startDate.timeIntervalSinceNow < 0 |
||||
} |
||||
if hasEnded() { |
||||
return true |
||||
} |
||||
return false |
||||
|
||||
//todo scores |
||||
// if let score { |
||||
// return score.hasEnded == false && score.sets.isEmpty == false |
||||
// } else { |
||||
// return false |
||||
// } |
||||
} |
||||
|
||||
var roundObject: Round? { |
||||
guard let round else { return nil } |
||||
return self.tournamentStore.rounds.findById(round) |
||||
} |
||||
|
||||
var groupStageObject: GroupStage? { |
||||
guard let groupStage else { return nil } |
||||
return self.tournamentStore.groupStages.findById(groupStage) |
||||
} |
||||
|
||||
var isLoserBracket: Bool { |
||||
return roundObject?.parent != nil |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _round = "round" |
||||
case _groupStage = "groupStage" |
||||
case _startDate = "startDate" |
||||
case _endDate = "endDate" |
||||
case _index = "index" |
||||
case _format = "format" |
||||
// case _court = "court" |
||||
case _courtIndex = "courtIndex" |
||||
case _servingTeamId = "servingTeamId" |
||||
case _winningTeamId = "winningTeamId" |
||||
case _losingTeamId = "losingTeamId" |
||||
// case _broadcasted = "broadcasted" |
||||
case _name = "name" |
||||
// case _order = "order" |
||||
case _disabled = "disabled" |
||||
case _confirmed = "confirmed" |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
|
||||
if let round = round { |
||||
try container.encode(round, forKey: ._round) |
||||
} else { |
||||
try container.encodeNil(forKey: ._round) |
||||
} |
||||
|
||||
if let groupStage = groupStage { |
||||
try container.encode(groupStage, forKey: ._groupStage) |
||||
} else { |
||||
try container.encodeNil(forKey: ._groupStage) |
||||
} |
||||
|
||||
if let startDate = startDate { |
||||
try container.encode(startDate, forKey: ._startDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._startDate) |
||||
} |
||||
|
||||
if let endDate = endDate { |
||||
try container.encode(endDate, forKey: ._endDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._endDate) |
||||
} |
||||
|
||||
try container.encode(index, forKey: ._index) |
||||
|
||||
if let format = format { |
||||
try container.encode(format, forKey: ._format) |
||||
} else { |
||||
try container.encodeNil(forKey: ._format) |
||||
} |
||||
|
||||
if let servingTeamId = servingTeamId { |
||||
try container.encode(servingTeamId, forKey: ._servingTeamId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._servingTeamId) |
||||
} |
||||
|
||||
if let winningTeamId = winningTeamId { |
||||
try container.encode(winningTeamId, forKey: ._winningTeamId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._winningTeamId) |
||||
} |
||||
|
||||
if let losingTeamId = losingTeamId { |
||||
try container.encode(losingTeamId, forKey: ._losingTeamId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._losingTeamId) |
||||
} |
||||
|
||||
if let name = name { |
||||
try container.encode(name, forKey: ._name) |
||||
} else { |
||||
try container.encodeNil(forKey: ._name) |
||||
} |
||||
|
||||
try container.encode(disabled, forKey: ._disabled) |
||||
|
||||
if let courtIndex = courtIndex { |
||||
try container.encode(courtIndex, forKey: ._courtIndex) |
||||
} else { |
||||
try container.encodeNil(forKey: ._courtIndex) |
||||
} |
||||
|
||||
try container.encode(confirmed, forKey: ._confirmed) |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.matches.writeChangeAndInsertOnServer(instance: self) |
||||
for teamScore in self.teamScores { |
||||
try teamScore.insertOnServer() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
enum MatchDateSetup: Hashable, Identifiable { |
||||
case inMinutes(Int) |
||||
case now |
||||
case customDate |
||||
|
||||
var id: Int { hashValue } |
||||
} |
||||
|
||||
enum MatchFieldSetup: Hashable, Identifiable { |
||||
case random |
||||
case fullRandom |
||||
// case firstAvailable |
||||
case field(Int) |
||||
|
||||
var id: Int { hashValue } |
||||
} |
||||
@ -1,746 +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 = false |
||||
|
||||
init(tournament: String, |
||||
timeDifferenceLimit: Int = 5, |
||||
loserBracketRotationDifference: Int = 0, |
||||
upperBracketRotationDifference: Int = 1, |
||||
accountUpperBracketBreakTime: Bool = true, |
||||
accountLoserBracketBreakTime: Bool = false, |
||||
randomizeCourts: Bool = true, |
||||
rotationDifferenceIsImportant: Bool = false, |
||||
shouldHandleUpperRoundSlice: Bool = true, |
||||
shouldEndRoundBeforeStartingNext: Bool = true, |
||||
groupStageChunkCount: Int? = nil, overrideCourtsUnavailability: Bool = false, shouldTryToFillUpCourtsAvailable: Bool = false) { |
||||
self.tournament = tournament |
||||
self.timeDifferenceLimit = timeDifferenceLimit |
||||
self.loserBracketRotationDifference = loserBracketRotationDifference |
||||
self.upperBracketRotationDifference = upperBracketRotationDifference |
||||
self.accountUpperBracketBreakTime = accountUpperBracketBreakTime |
||||
self.accountLoserBracketBreakTime = accountLoserBracketBreakTime |
||||
self.randomizeCourts = randomizeCourts |
||||
self.rotationDifferenceIsImportant = rotationDifferenceIsImportant |
||||
self.shouldHandleUpperRoundSlice = shouldHandleUpperRoundSlice |
||||
self.shouldEndRoundBeforeStartingNext = shouldEndRoundBeforeStartingNext |
||||
self.groupStageChunkCount = groupStageChunkCount |
||||
self.overrideCourtsUnavailability = overrideCourtsUnavailability |
||||
self.shouldTryToFillUpCourtsAvailable = shouldTryToFillUpCourtsAvailable |
||||
} |
||||
|
||||
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" |
||||
} |
||||
|
||||
var courtsUnavailability: [DateInterval]? { |
||||
guard let event = tournamentObject()?.eventObject() else { return nil } |
||||
return event.courtsUnavailability + (overrideCourtsUnavailability ? [] : event.tournamentsCourtsUsed(exluding: tournament)) |
||||
} |
||||
|
||||
var additionalEstimationDuration : Int { |
||||
return tournamentObject()?.additionalEstimationDuration ?? 0 |
||||
} |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
return TournamentStore.instance(tournamentId: self.tournament) |
||||
} |
||||
|
||||
func tournamentObject() -> Tournament? { |
||||
return Store.main.findById(tournament) |
||||
} |
||||
|
||||
@discardableResult |
||||
func updateGroupStageSchedule(tournament: Tournament) -> Date { |
||||
let computedGroupStageChunkCount = groupStageChunkCount ?? tournament.getGroupStageChunkValue() |
||||
let groupStages: [GroupStage] = tournament.groupStages() |
||||
let numberOfCourtsAvailablePerRotation: Int = tournament.courtCount |
||||
|
||||
let matches = groupStages.flatMap { $0._matches() } |
||||
matches.forEach({ |
||||
$0.removeCourt() |
||||
$0.startDate = nil |
||||
$0.confirmed = false |
||||
}) |
||||
|
||||
var lastDate : Date = tournament.startDate |
||||
let times = Set(groupStages.compactMap { $0.startDate }).sorted() |
||||
|
||||
if let first = times.first { |
||||
if first.isEarlierThan(tournament.startDate) { |
||||
tournament.startDate = first |
||||
do { |
||||
try DataStore.shared.tournaments.addOrUpdate(instance: tournament) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
times.forEach({ time in |
||||
lastDate = time |
||||
let groups = groupStages.filter({ $0.startDate == time }) |
||||
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) |
||||
|
||||
dispatch.timedMatches.forEach { matchSchedule in |
||||
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { |
||||
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) |
||||
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 |
||||
if let startDate = match.groupStageObject?.startDate { |
||||
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) |
||||
match.startDate = matchStartDate |
||||
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) |
||||
} |
||||
match.setCourt(matchSchedule.courtIndex) |
||||
} |
||||
} |
||||
}) |
||||
|
||||
groupStages.filter({ $0.startDate == nil || times.contains($0.startDate!) == false }).chunked(into: computedGroupStageChunkCount).forEach { groups in |
||||
groups.forEach({ $0.startDate = lastDate }) |
||||
do { |
||||
try self.tournamentStore.groupStages.addOrUpdate(contentOfs: groups) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
let dispatch = groupStageDispatcher(numberOfCourtsAvailablePerRotation: numberOfCourtsAvailablePerRotation, groupStages: groups, startingDate: lastDate) |
||||
|
||||
dispatch.timedMatches.forEach { matchSchedule in |
||||
if let match = matches.first(where: { $0.id == matchSchedule.matchID }) { |
||||
let estimatedDuration = match.matchFormat.getEstimatedDuration(tournament.additionalEstimationDuration) |
||||
let timeIntervalToAdd = (Double(matchSchedule.rotationIndex)) * Double(estimatedDuration) * 60 |
||||
if let startDate = match.groupStageObject?.startDate { |
||||
let matchStartDate = startDate.addingTimeInterval(timeIntervalToAdd) |
||||
match.startDate = matchStartDate |
||||
lastDate = matchStartDate.addingTimeInterval(Double(estimatedDuration) * 60) |
||||
} |
||||
match.setCourt(matchSchedule.courtIndex) |
||||
} |
||||
} |
||||
} |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
return lastDate |
||||
} |
||||
|
||||
func groupStageDispatcher(numberOfCourtsAvailablePerRotation: Int, groupStages: [GroupStage], startingDate: Date) -> GroupStageMatchDispatcher { |
||||
|
||||
let _groupStages = groupStages |
||||
|
||||
// Get the maximum count of matches in any group |
||||
let maxMatchesCount = _groupStages.map { $0._matches().count }.max() ?? 0 |
||||
|
||||
// Use zip and flatMap to flatten matches in the desired order |
||||
let flattenedMatches = (0..<maxMatchesCount).flatMap { index in |
||||
_groupStages.compactMap { group in |
||||
// Use optional subscript to safely access matches |
||||
let playedMatches = group.playedMatches() |
||||
return playedMatches.indices.contains(index) ? playedMatches[index] : nil |
||||
} |
||||
} |
||||
|
||||
var slots = [GroupStageTimeMatch]() |
||||
var availableMatchs = flattenedMatches |
||||
var rotationIndex = 0 |
||||
var teamsPerRotation = [Int: [String]]() |
||||
var freeCourtPerRotation = [Int: [Int]]() |
||||
var groupLastRotation = [Int: Int]() |
||||
let courtsUnavailability = courtsUnavailability |
||||
|
||||
while slots.count < flattenedMatches.count { |
||||
teamsPerRotation[rotationIndex] = [] |
||||
freeCourtPerRotation[rotationIndex] = [] |
||||
let previousRotationBracketIndexes = slots.filter { $0.rotationIndex == rotationIndex - 1 }.map { ($0.groupIndex, 1) } |
||||
let counts = Dictionary(previousRotationBracketIndexes, uniquingKeysWith: +) |
||||
var rotationMatches = Array(availableMatchs.filter({ match in |
||||
teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true |
||||
}).prefix(numberOfCourtsAvailablePerRotation)) |
||||
|
||||
if rotationIndex > 0 { |
||||
rotationMatches = rotationMatches.sorted(by: { |
||||
if counts[$0.groupStageObject!.index] ?? 0 == counts[$1.groupStageObject!.index] ?? 0 { |
||||
return $0.groupStageObject!.index < $1.groupStageObject!.index |
||||
} else { |
||||
return counts[$0.groupStageObject!.index] ?? 0 < counts[$1.groupStageObject!.index] ?? 0 |
||||
} |
||||
}) |
||||
} |
||||
|
||||
(0..<numberOfCourtsAvailablePerRotation).forEach { courtIndex in |
||||
//print(mt.map { ($0.bracket!.index.intValue, counts[$0.bracket!.index.intValue]) }) |
||||
if let first = rotationMatches.first(where: { match in |
||||
let estimatedDuration = match.matchFormat.getEstimatedDuration(additionalEstimationDuration) |
||||
let timeIntervalToAdd = (Double(rotationIndex)) * Double(estimatedDuration) * 60 |
||||
|
||||
let rotationStartDate: Date = startingDate.addingTimeInterval(timeIntervalToAdd) |
||||
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability) |
||||
if courtIndex >= numberOfCourtsAvailablePerRotation - courtsUnavailable.count { |
||||
return false |
||||
} else { |
||||
return teamsPerRotation[rotationIndex]!.allSatisfy({ match.containsTeamId($0) == false }) == true |
||||
} |
||||
}) { |
||||
let timeMatch = GroupStageTimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, groupIndex: first.groupStageObject!.index) |
||||
slots.append(timeMatch) |
||||
teamsPerRotation[rotationIndex]!.append(contentsOf: first.teamIds()) |
||||
rotationMatches.removeAll(where: { $0.id == first.id }) |
||||
availableMatchs.removeAll(where: { $0.id == first.id }) |
||||
if let index = first.groupStageObject?.index { |
||||
groupLastRotation[index] = rotationIndex |
||||
} |
||||
} else { |
||||
freeCourtPerRotation[rotationIndex]!.append(courtIndex) |
||||
} |
||||
} |
||||
|
||||
rotationIndex += 1 |
||||
} |
||||
|
||||
var organizedSlots = [GroupStageTimeMatch]() |
||||
for i in 0..<rotationIndex { |
||||
let courtsSorted: [Int] = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted() |
||||
let courts: [Int] = randomizeCourts ? courtsSorted.shuffled() : courtsSorted |
||||
var matches = slots.filter({ $0.rotationIndex == i }).sorted(using: .keyPath(\.groupIndex), .keyPath(\.courtIndex)) |
||||
|
||||
for j in 0..<matches.count { |
||||
matches[j].courtIndex = courts[j] |
||||
organizedSlots.append(matches[j]) |
||||
} |
||||
} |
||||
|
||||
|
||||
return GroupStageMatchDispatcher(timedMatches: organizedSlots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, groupLastRotation: groupLastRotation) |
||||
} |
||||
|
||||
func rotationDifference(loserBracket: Bool) -> Int { |
||||
if loserBracket { |
||||
return loserBracketRotationDifference |
||||
} else { |
||||
return upperBracketRotationDifference |
||||
} |
||||
} |
||||
|
||||
func roundMatchCanBePlayed(_ match: Match, roundObject: Round, slots: [TimeMatch], rotationIndex: Int, targetedStartDate: Date, minimumTargetedEndDate: inout Date) -> Bool { |
||||
print(roundObject.roundTitle(), match.matchTitle()) |
||||
|
||||
if let roundStartDate = roundObject.startDate, targetedStartDate < roundStartDate { |
||||
print("can't start \(targetedStartDate) earlier than \(roundStartDate)") |
||||
if targetedStartDate == minimumTargetedEndDate { |
||||
minimumTargetedEndDate = roundStartDate |
||||
} else { |
||||
minimumTargetedEndDate = min(roundStartDate, minimumTargetedEndDate) |
||||
} |
||||
return false |
||||
} |
||||
|
||||
let previousMatches = roundObject.precedentMatches(ofMatch: match) |
||||
if previousMatches.isEmpty { return true } |
||||
|
||||
let previousMatchSlots = slots.filter({ slot in |
||||
previousMatches.map { $0.id }.contains(slot.matchID) |
||||
}) |
||||
|
||||
if previousMatchSlots.isEmpty { |
||||
if previousMatches.filter({ $0.disabled == false }).allSatisfy({ $0.startDate != nil }) { |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
if previousMatches.filter({ $0.disabled == false }).count > previousMatchSlots.count { |
||||
if previousMatches.filter({ $0.disabled == false }).anySatisfy({ $0.startDate != nil }) { |
||||
return true |
||||
} |
||||
return false |
||||
} |
||||
|
||||
var includeBreakTime = false |
||||
|
||||
if accountLoserBracketBreakTime && roundObject.isLoserBracket() { |
||||
includeBreakTime = true |
||||
} |
||||
|
||||
if accountUpperBracketBreakTime && roundObject.isLoserBracket() == false { |
||||
includeBreakTime = true |
||||
} |
||||
|
||||
let previousMatchIsInPreviousRotation = previousMatchSlots.allSatisfy({ $0.rotationIndex + rotationDifference(loserBracket: roundObject.isLoserBracket()) < rotationIndex }) |
||||
|
||||
guard let minimumPossibleEndDate = previousMatchSlots.map({ $0.estimatedEndDate(includeBreakTime: includeBreakTime) }).max() else { |
||||
return previousMatchIsInPreviousRotation |
||||
} |
||||
|
||||
if targetedStartDate >= minimumPossibleEndDate { |
||||
if rotationDifferenceIsImportant { |
||||
return previousMatchIsInPreviousRotation |
||||
} else { |
||||
return true |
||||
} |
||||
} else { |
||||
if targetedStartDate == minimumTargetedEndDate { |
||||
minimumTargetedEndDate = minimumPossibleEndDate |
||||
} else { |
||||
minimumTargetedEndDate = min(minimumPossibleEndDate, minimumTargetedEndDate) |
||||
} |
||||
return false |
||||
} |
||||
} |
||||
|
||||
func getNextStartDate(fromPreviousRotationSlots slots: [TimeMatch], includeBreakTime: Bool) -> Date? { |
||||
slots.map { $0.estimatedEndDate(includeBreakTime: includeBreakTime) }.min() |
||||
} |
||||
|
||||
func getNextEarliestAvailableDate(from slots: [TimeMatch]) -> [(Int, Date)] { |
||||
let byCourt = Dictionary(grouping: slots, by: { $0.courtIndex }) |
||||
return (byCourt.keys.flatMap { courtIndex in |
||||
let matchesByCourt = byCourt[courtIndex]?.sorted(by: \.startDate) |
||||
let lastMatch = matchesByCourt?.last |
||||
var results = [(Int, Date)]() |
||||
if let courtFreeDate = lastMatch?.estimatedEndDate(includeBreakTime: false) { |
||||
results.append((courtIndex, courtFreeDate)) |
||||
} |
||||
return results |
||||
} |
||||
) |
||||
} |
||||
|
||||
func getAvailableCourts(from matches: [Match]) -> [(Int, Date)] { |
||||
let validMatches = matches.filter({ $0.courtIndex != nil && $0.startDate != nil }) |
||||
let byCourt = Dictionary(grouping: validMatches, by: { $0.courtIndex! }) |
||||
return (byCourt.keys.flatMap { court in |
||||
let matchesByCourt = byCourt[court]?.sorted(by: \.startDate!) |
||||
let lastMatch = matchesByCourt?.last |
||||
var results = [(Int, Date)]() |
||||
if let courtFreeDate = lastMatch?.estimatedEndDate(additionalEstimationDuration) { |
||||
results.append((court, courtFreeDate)) |
||||
} |
||||
return results |
||||
} |
||||
) |
||||
} |
||||
|
||||
func roundDispatcher(numberOfCourtsAvailablePerRotation: Int, flattenedMatches: [Match], dispatcherStartDate: Date, initialCourts: [Int]?) -> MatchDispatcher { |
||||
|
||||
var slots = [TimeMatch]() |
||||
var _startDate: Date? |
||||
var rotationIndex = 0 |
||||
var availableMatchs = flattenedMatches.filter({ $0.startDate == nil }) |
||||
let courtsUnavailability = courtsUnavailability |
||||
var issueFound: Bool = false |
||||
|
||||
flattenedMatches.filter { $0.startDate != nil }.sorted(by: \.startDate!).forEach { match in |
||||
if _startDate == nil { |
||||
_startDate = match.startDate |
||||
} else if match.startDate! > _startDate! { |
||||
_startDate = match.startDate |
||||
rotationIndex += 1 |
||||
} |
||||
|
||||
let timeMatch = TimeMatch(matchID: match.id, rotationIndex: rotationIndex, courtIndex: match.courtIndex ?? 0, startDate: match.startDate!, durationLeft: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: match.matchFormat.breakTime.breakTime) |
||||
slots.append(timeMatch) |
||||
} |
||||
|
||||
if slots.isEmpty == false { |
||||
rotationIndex += 1 |
||||
} |
||||
|
||||
var freeCourtPerRotation = [Int: [Int]]() |
||||
|
||||
let availableCourt = numberOfCourtsAvailablePerRotation |
||||
|
||||
var courts = initialCourts ?? (0..<availableCourt).map { $0 } |
||||
|
||||
var shouldStartAtDispatcherDate = rotationIndex > 0 |
||||
|
||||
while availableMatchs.count > 0 && issueFound == false { |
||||
freeCourtPerRotation[rotationIndex] = [] |
||||
let previousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 1 }) |
||||
var rotationStartDate: Date = getNextStartDate(fromPreviousRotationSlots: previousRotationSlots, includeBreakTime: false) ?? dispatcherStartDate |
||||
|
||||
if shouldStartAtDispatcherDate { |
||||
rotationStartDate = dispatcherStartDate |
||||
shouldStartAtDispatcherDate = false |
||||
} else { |
||||
courts = rotationIndex == 0 ? courts : (0..<availableCourt).map { $0 } |
||||
} |
||||
courts.sort() |
||||
print("courts available at rotation \(rotationIndex)", courts) |
||||
print("rotationStartDate", rotationStartDate) |
||||
|
||||
if rotationIndex > 0, let freeCourtPreviousRotation = freeCourtPerRotation[rotationIndex - 1], freeCourtPreviousRotation.count > 0 { |
||||
print("scenario where we are waiting for a breaktime to be over without any match to play in between or a free court was available and we need to recheck breaktime left on it") |
||||
let previousPreviousRotationSlots = slots.filter({ $0.rotationIndex == rotationIndex - 2 && freeCourtPreviousRotation.contains($0.courtIndex) }) |
||||
let previousEndDate = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: accountUpperBracketBreakTime) |
||||
let previousEndDateNoBreak = getNextStartDate(fromPreviousRotationSlots: previousPreviousRotationSlots, includeBreakTime: false) |
||||
|
||||
let noBreakAlreadyTested = previousRotationSlots.anySatisfy({ $0.startDate == previousEndDateNoBreak }) |
||||
if let previousEndDate, let previousEndDateNoBreak { |
||||
let differenceWithBreak = rotationStartDate.timeIntervalSince(previousEndDate) |
||||
let differenceWithoutBreak = rotationStartDate.timeIntervalSince(previousEndDateNoBreak) |
||||
print("difference w break", differenceWithBreak) |
||||
print("difference w/o break", differenceWithoutBreak) |
||||
let timeDifferenceLimitInSeconds = Double(timeDifferenceLimit * 60) |
||||
var difference = differenceWithBreak |
||||
if differenceWithBreak <= 0 { |
||||
difference = differenceWithoutBreak |
||||
} else if differenceWithBreak > timeDifferenceLimitInSeconds && differenceWithoutBreak > timeDifferenceLimitInSeconds { |
||||
difference = noBreakAlreadyTested ? differenceWithBreak : max(differenceWithBreak, differenceWithoutBreak) |
||||
} |
||||
|
||||
if difference > timeDifferenceLimitInSeconds && rotationStartDate.addingTimeInterval(-difference) != previousEndDate { |
||||
courts.removeAll(where: { index in freeCourtPreviousRotation.contains(index) |
||||
}) |
||||
freeCourtPerRotation[rotationIndex] = courts |
||||
courts = freeCourtPreviousRotation |
||||
rotationStartDate = rotationStartDate.addingTimeInterval(-difference) |
||||
} |
||||
} |
||||
} else if let first = availableMatchs.first { |
||||
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration) |
||||
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: duration, courtsUnavailability: courtsUnavailability) |
||||
|
||||
if courtsUnavailable.count == numberOfCourtsAvailablePerRotation { |
||||
print("issue") |
||||
issueFound = true |
||||
} else { |
||||
courts = Array(Set(courts).subtracting(Set(courtsUnavailable))) |
||||
} |
||||
} |
||||
|
||||
dispatchCourts(availableCourts: numberOfCourtsAvailablePerRotation, courts: courts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: rotationStartDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) |
||||
rotationIndex += 1 |
||||
} |
||||
|
||||
var organizedSlots = [TimeMatch]() |
||||
for i in 0..<rotationIndex { |
||||
let courtsSorted = slots.filter({ $0.rotationIndex == i }).map { $0.courtIndex }.sorted() |
||||
let courts = randomizeCourts ? courtsSorted.shuffled() : courtsSorted |
||||
var matches = slots.filter({ $0.rotationIndex == i }).sorted(using: .keyPath(\.courtIndex)) |
||||
|
||||
for j in 0..<matches.count { |
||||
matches[j].courtIndex = courts[j] |
||||
organizedSlots.append(matches[j]) |
||||
} |
||||
} |
||||
|
||||
|
||||
return MatchDispatcher(timedMatches: slots, freeCourtPerRotation: freeCourtPerRotation, rotationCount: rotationIndex, issueFound: issueFound) |
||||
} |
||||
|
||||
func dispatchCourts(availableCourts: Int, courts: [Int], availableMatchs: inout [Match], slots: inout [TimeMatch], rotationIndex: Int, rotationStartDate: Date, freeCourtPerRotation: inout [Int: [Int]], courtsUnavailability: [DateInterval]?) { |
||||
var matchPerRound = [String: Int]() |
||||
var minimumTargetedEndDate: Date = rotationStartDate |
||||
print("dispatchCourts", courts.sorted(), rotationStartDate, rotationIndex) |
||||
for (courtPosition, courtIndex) in courts.sorted().enumerated() { |
||||
if let first = availableMatchs.first(where: { match in |
||||
print("trying to find a match for \(courtIndex) in \(rotationIndex)") |
||||
let roundObject = match.roundObject! |
||||
let courtsUnavailable = courtsUnavailable(startDate: rotationStartDate, duration: match.matchFormat.getEstimatedDuration(additionalEstimationDuration), courtsUnavailability: courtsUnavailability) |
||||
print("courtsUnavailable \(courtsUnavailable)") |
||||
if courtPosition >= availableCourts - courtsUnavailable.count { |
||||
return false |
||||
} |
||||
|
||||
let canBePlayed = roundMatchCanBePlayed(match, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) |
||||
|
||||
let currentRotationSameRoundMatches = matchPerRound[roundObject.id] ?? 0 |
||||
|
||||
let roundMatchesCount = roundObject.playedMatches().count |
||||
|
||||
if shouldHandleUpperRoundSlice { |
||||
print("shouldHandleUpperRoundSlice \(roundMatchesCount)") |
||||
if roundObject.parent == nil && roundMatchesCount > courts.count { |
||||
print("roundMatchesCount \(roundMatchesCount) > \(courts.count)") |
||||
if currentRotationSameRoundMatches >= min(roundMatchesCount / 2, courts.count) { |
||||
print("return false, \(currentRotationSameRoundMatches) >= \(min(roundMatchesCount / 2, courts.count))") |
||||
return false |
||||
} |
||||
} |
||||
} |
||||
|
||||
//if all is ok, we do a final check to see if the first |
||||
let indexInRound = match.indexInRound() |
||||
|
||||
print("Upper Round, index > 0, first Match of round \(indexInRound) and more than one court available; looking for next match (same round) \(indexInRound + 1)") |
||||
if roundObject.parent == nil && roundObject.index > 0, indexInRound == 0, let nextMatch = match.next() { |
||||
guard courtPosition < courts.count - 1, courts.count > 1 else { |
||||
print("next match and this match can not be played at the same time, returning false") |
||||
return false |
||||
} |
||||
if canBePlayed && roundMatchCanBePlayed(nextMatch, roundObject: roundObject, slots: slots, rotationIndex: rotationIndex, targetedStartDate: rotationStartDate, minimumTargetedEndDate: &minimumTargetedEndDate) { |
||||
|
||||
print("next match and this match can be played, returning true") |
||||
|
||||
return true |
||||
} |
||||
} |
||||
|
||||
|
||||
//not adding a last match of a 4-match round (final not included obviously) |
||||
print("\(currentRotationSameRoundMatches) modulo \(currentRotationSameRoundMatches%2) same round match is even, index of round is not 0 and upper bracket. If it's not the last court available \(courtIndex) == \(courts.count - 1)") |
||||
|
||||
if shouldTryToFillUpCourtsAvailable == false { |
||||
if roundMatchesCount <= 4 && currentRotationSameRoundMatches%2 == 0 && roundObject.index != 0 && roundObject.parent == nil && ((courts.count > 1 && courtPosition >= courts.count - 1) || courts.count == 1 && availableCourts > 1) { |
||||
print("we return false") |
||||
return false |
||||
} |
||||
} |
||||
|
||||
|
||||
return canBePlayed |
||||
}) { |
||||
print(first.roundObject!.roundTitle(), first.matchTitle(), courtIndex, rotationStartDate) |
||||
|
||||
if first.roundObject!.parent == nil { |
||||
if let roundIndex = matchPerRound[first.roundObject!.id] { |
||||
matchPerRound[first.roundObject!.id] = roundIndex + 1 |
||||
} else { |
||||
matchPerRound[first.roundObject!.id] = 1 |
||||
} |
||||
} |
||||
let timeMatch = TimeMatch(matchID: first.id, rotationIndex: rotationIndex, courtIndex: courtIndex, startDate: rotationStartDate, durationLeft: first.matchFormat.getEstimatedDuration(additionalEstimationDuration), minimumBreakTime: first.matchFormat.breakTime.breakTime) |
||||
slots.append(timeMatch) |
||||
availableMatchs.removeAll(where: { $0.id == first.id }) |
||||
} else { |
||||
freeCourtPerRotation[rotationIndex]!.append(courtIndex) |
||||
} |
||||
} |
||||
|
||||
if freeCourtPerRotation[rotationIndex]!.count == availableCourts { |
||||
print("no match found to be put in this rotation, check if we can put anything to another date") |
||||
freeCourtPerRotation[rotationIndex] = [] |
||||
let courtsUsed = getNextEarliestAvailableDate(from: slots) |
||||
var freeCourts: [Int] = [] |
||||
if courtsUsed.isEmpty { |
||||
freeCourts = (0..<availableCourts).map { $0 } |
||||
} else { |
||||
freeCourts = courtsUsed.filter { (courtIndex, availableDate) in |
||||
availableDate <= minimumTargetedEndDate |
||||
}.sorted(by: \.1).map { $0.0 } |
||||
} |
||||
|
||||
|
||||
if let first = availableMatchs.first { |
||||
let duration = first.matchFormat.getEstimatedDuration(additionalEstimationDuration) |
||||
let courtsUnavailable = courtsUnavailable(startDate: minimumTargetedEndDate, duration: duration, courtsUnavailability: courtsUnavailability) |
||||
if courtsUnavailable.count < availableCourts { |
||||
dispatchCourts(availableCourts: availableCourts, courts: freeCourts, availableMatchs: &availableMatchs, slots: &slots, rotationIndex: rotationIndex, rotationStartDate: minimumTargetedEndDate, freeCourtPerRotation: &freeCourtPerRotation, courtsUnavailability: courtsUnavailability) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
@discardableResult func updateBracketSchedule(tournament: Tournament, fromRoundId roundId: String?, fromMatchId matchId: String?, startDate: Date) -> Bool { |
||||
|
||||
let upperRounds: [Round] = tournament.rounds() |
||||
let allMatches: [Match] = tournament.allMatches() |
||||
|
||||
var rounds = [Round]() |
||||
|
||||
if shouldEndRoundBeforeStartingNext { |
||||
rounds = upperRounds.flatMap { |
||||
[$0] + $0.loserRoundsAndChildren() |
||||
} |
||||
} else { |
||||
rounds = upperRounds.map { |
||||
$0 |
||||
} + upperRounds.flatMap { |
||||
$0.loserRoundsAndChildren() |
||||
} |
||||
} |
||||
|
||||
let flattenedMatches = rounds.flatMap { round in |
||||
round._matches().filter({ $0.disabled == false }).sorted(by: \.index) |
||||
} |
||||
|
||||
flattenedMatches.forEach({ |
||||
if (roundId == nil && matchId == nil) || $0.startDate?.isEarlierThan(startDate) == false { |
||||
$0.startDate = nil |
||||
$0.removeCourt() |
||||
$0.confirmed = false |
||||
} |
||||
}) |
||||
|
||||
// if let roundId { |
||||
// if let round : Round = Store.main.findById(roundId) { |
||||
// let matches = round._matches().filter({ $0.disabled == false }).sorted(by: \.index) |
||||
// round.resetFromRoundAllMatchesStartDate() |
||||
// flattenedMatches = matches + flattenedMatches |
||||
// } |
||||
// |
||||
// } else if let matchId { |
||||
// if let match : Match = Store.main.findById(matchId) { |
||||
// if let round = match.roundObject { |
||||
// round.resetFromRoundAllMatchesStartDate(from: match) |
||||
// } |
||||
// flattenedMatches = [match] + flattenedMatches |
||||
// } |
||||
// } |
||||
|
||||
if let roundId, let matchId { |
||||
//todo |
||||
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId && $0.id == matchId }) { |
||||
flattenedMatches[index...].forEach { |
||||
$0.startDate = nil |
||||
$0.removeCourt() |
||||
$0.confirmed = false |
||||
} |
||||
} |
||||
} else if let roundId { |
||||
//todo |
||||
if let index = flattenedMatches.firstIndex(where: { $0.round == roundId }) { |
||||
flattenedMatches[index...].forEach { |
||||
$0.startDate = nil |
||||
$0.removeCourt() |
||||
$0.confirmed = false |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
let matches: [Match] = allMatches.filter { $0.startDate?.isEarlierThan(startDate) == true && $0.startDate?.dayInt == startDate.dayInt } |
||||
let usedCourts = getAvailableCourts(from: matches) |
||||
let initialCourts: [Int] = usedCourts.filter { (court, availableDate) in |
||||
availableDate <= startDate |
||||
}.sorted(by: \.1).compactMap { $0.0 } |
||||
|
||||
let courts : [Int]? = initialCourts.isEmpty ? nil : initialCourts |
||||
|
||||
print("initial available courts at beginning: \(courts ?? [])") |
||||
|
||||
let roundDispatch = self.roundDispatcher(numberOfCourtsAvailablePerRotation: tournament.courtCount, flattenedMatches: flattenedMatches, dispatcherStartDate: startDate, initialCourts: courts) |
||||
|
||||
roundDispatch.timedMatches.forEach { matchSchedule in |
||||
if let match = flattenedMatches.first(where: { $0.id == matchSchedule.matchID }) { |
||||
match.startDate = matchSchedule.startDate |
||||
match.setCourt(matchSchedule.courtIndex) |
||||
} |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: allMatches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
return roundDispatch.issueFound |
||||
} |
||||
|
||||
|
||||
func courtsUnavailable(startDate: Date, duration: Int, courtsUnavailability: [DateInterval]?) -> [Int] { |
||||
let endDate = startDate.addingTimeInterval(Double(duration) * 60) |
||||
guard let courtsUnavailability else { return [] } |
||||
let groupedBy = Dictionary(grouping: courtsUnavailability, by: { $0.courtIndex }) |
||||
let courts = groupedBy.keys |
||||
return courts.filter { |
||||
courtUnavailable(courtIndex: $0, from: startDate, to: endDate, source: courtsUnavailability) |
||||
} |
||||
} |
||||
|
||||
func courtUnavailable(courtIndex: Int, from startDate: Date, to endDate: Date, source: [DateInterval]) -> Bool { |
||||
let courtLockedSchedule = source.filter({ $0.courtIndex == courtIndex }) |
||||
return courtLockedSchedule.anySatisfy({ dateInterval in |
||||
let range = startDate..<endDate |
||||
return dateInterval.range.overlaps(range) |
||||
}) |
||||
} |
||||
|
||||
func updateSchedule(tournament: Tournament) -> Bool { |
||||
var lastDate = tournament.startDate |
||||
if tournament.groupStageCount > 0 { |
||||
lastDate = updateGroupStageSchedule(tournament: tournament) |
||||
} |
||||
return updateBracketSchedule(tournament: tournament, fromRoundId: nil, fromMatchId: nil, startDate: lastDate) |
||||
} |
||||
} |
||||
|
||||
struct GroupStageTimeMatch { |
||||
let matchID: String |
||||
let rotationIndex: Int |
||||
var courtIndex: Int |
||||
let groupIndex: Int |
||||
} |
||||
|
||||
struct TimeMatch { |
||||
let matchID: String |
||||
let rotationIndex: Int |
||||
var courtIndex: Int |
||||
var startDate: Date |
||||
var durationLeft: Int //in minutes |
||||
var minimumBreakTime: Int //in minutes |
||||
|
||||
func estimatedEndDate(includeBreakTime: Bool) -> Date { |
||||
let minutesToAdd = Double(durationLeft + (includeBreakTime ? minimumBreakTime : 0)) |
||||
return startDate.addingTimeInterval(minutesToAdd * 60.0) |
||||
} |
||||
} |
||||
|
||||
struct GroupStageMatchDispatcher { |
||||
let timedMatches: [GroupStageTimeMatch] |
||||
let freeCourtPerRotation: [Int: [Int]] |
||||
let rotationCount: Int |
||||
let groupLastRotation: [Int: Int] |
||||
} |
||||
|
||||
struct MatchDispatcher { |
||||
let timedMatches: [TimeMatch] |
||||
let freeCourtPerRotation: [Int: [Int]] |
||||
let rotationCount: Int |
||||
let issueFound: Bool |
||||
} |
||||
|
||||
extension Match { |
||||
func teamIds() -> [String] { |
||||
return teams().map { $0.id } |
||||
} |
||||
|
||||
func containsTeamId(_ id: String) -> Bool { |
||||
return teamIds().contains(id) |
||||
} |
||||
} |
||||
@ -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,101 +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 |
||||
|
||||
init(monthKey: String) { |
||||
self.monthKey = monthKey |
||||
self.creationDate = Date() |
||||
} |
||||
|
||||
fileprivate func _updateCreationDate() { |
||||
self.creationDate = Date() |
||||
} |
||||
|
||||
required init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
id = try container.decode(String.self, forKey: ._id) |
||||
monthKey = try container.decode(String.self, forKey: ._monthKey) |
||||
creationDate = try container.decode(Date.self, forKey: ._creationDate) |
||||
maleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._maleUnrankedValue) |
||||
femaleUnrankedValue = try container.decodeIfPresent(Int.self, forKey: ._femaleUnrankedValue) |
||||
maleCount = try container.decodeIfPresent(Int.self, forKey: ._maleCount) |
||||
femaleCount = try container.decodeIfPresent(Int.self, forKey: ._femaleCount) |
||||
anonymousCount = try container.decodeIfPresent(Int.self, forKey: ._anonymousCount) |
||||
incompleteMode = try container.decodeIfPresent(Bool.self, forKey: ._incompleteMode) ?? false |
||||
|
||||
} |
||||
|
||||
func total() -> Int { |
||||
return (maleCount ?? 0) + (femaleCount ?? 0) |
||||
} |
||||
|
||||
static func calculateCurrentUnrankedValues(fromDate: Date) async { |
||||
|
||||
let fileURL = SourceFileManager.shared.allFiles(true).first(where: { $0.dateFromPath == fromDate && $0.index == 0 }) |
||||
print("calculateCurrentUnrankedValues", fromDate.monthYearFormatted, fileURL?.path()) |
||||
let fftImportingUncomplete = fileURL?.fftImportingUncomplete() |
||||
let fftImportingMaleUnrankValue = fileURL?.fftImportingMaleUnrankValue() |
||||
|
||||
let incompleteMode = fftImportingUncomplete != nil |
||||
|
||||
let lastDataSourceMaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: true) |
||||
let lastDataSourceFemaleUnranked = await FederalPlayer.lastRank(mostRecentDateAvailable: fromDate, man: false) |
||||
let anonymousCount = await FederalPlayer.anonymousCount(mostRecentDateAvailable: fromDate) |
||||
await MainActor.run { |
||||
let lastDataSource = URL.importDateFormatter.string(from: fromDate) |
||||
let currentMonthData : MonthData = DataStore.shared.monthData.first(where: { $0.monthKey == lastDataSource }) ?? MonthData(monthKey: lastDataSource) |
||||
currentMonthData._updateCreationDate() |
||||
currentMonthData.maleUnrankedValue = incompleteMode ? fftImportingMaleUnrankValue : lastDataSourceMaleUnranked?.0 |
||||
currentMonthData.incompleteMode = incompleteMode |
||||
currentMonthData.maleCount = incompleteMode ? fftImportingUncomplete : lastDataSourceMaleUnranked?.1 |
||||
currentMonthData.femaleUnrankedValue = lastDataSourceFemaleUnranked?.0 |
||||
currentMonthData.femaleCount = lastDataSourceFemaleUnranked?.1 |
||||
currentMonthData.anonymousCount = anonymousCount |
||||
do { |
||||
try DataStore.shared.monthData.addOrUpdate(instance: currentMonthData) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
override func deleteDependencies() throws { |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _monthKey = "monthKey" |
||||
case _creationDate = "creationDate" |
||||
case _maleUnrankedValue = "maleUnrankedValue" |
||||
case _femaleUnrankedValue = "femaleUnrankedValue" |
||||
case _maleCount = "maleCount" |
||||
case _femaleCount = "femaleCount" |
||||
case _anonymousCount = "anonymousCount" |
||||
case _incompleteMode = "incompleteMode" |
||||
} |
||||
} |
||||
@ -1,574 +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 |
||||
|
||||
init(teamRegistration: String? = nil, firstName: String, lastName: String, licenceId: String? = nil, rank: Int? = nil, paymentType: PlayerPaymentType? = nil, sex: PlayerSexType? = nil, tournamentPlayed: Int? = nil, points: Double? = nil, clubName: String? = nil, ligueName: String? = nil, assimilation: String? = nil, phoneNumber: String? = nil, email: String? = nil, birthdate: String? = nil, computedRank: Int = 0, source: PlayerDataSource? = nil, hasArrived: Bool = false) { |
||||
self.teamRegistration = teamRegistration |
||||
self.firstName = firstName |
||||
self.lastName = lastName |
||||
self.licenceId = licenceId |
||||
self.rank = rank |
||||
self.paymentType = paymentType |
||||
self.sex = sex |
||||
self.tournamentPlayed = tournamentPlayed |
||||
self.points = points |
||||
self.clubName = clubName |
||||
self.ligueName = ligueName |
||||
self.assimilation = assimilation |
||||
self.phoneNumber = phoneNumber |
||||
self.email = email |
||||
self.birthdate = birthdate |
||||
self.computedRank = computedRank |
||||
self.source = source |
||||
self.hasArrived = hasArrived |
||||
} |
||||
|
||||
internal init(importedPlayer: ImportedPlayer) { |
||||
self.teamRegistration = "" |
||||
self.firstName = (importedPlayer.firstName ?? "").trimmed.capitalized |
||||
self.lastName = (importedPlayer.lastName ?? "").trimmed.uppercased() |
||||
self.licenceId = importedPlayer.license ?? nil |
||||
self.rank = Int(importedPlayer.rank) |
||||
self.sex = importedPlayer.male ? .male : .female |
||||
self.tournamentPlayed = importedPlayer.tournamentPlayed |
||||
self.points = importedPlayer.getPoints() |
||||
self.clubName = importedPlayer.clubName |
||||
self.ligueName = importedPlayer.ligueName |
||||
self.assimilation = importedPlayer.assimilation |
||||
self.source = .frenchFederation |
||||
} |
||||
|
||||
internal init?(federalData: [String], sex: Int, sexUnknown: Bool) { |
||||
let _lastName = federalData[0].trimmed.uppercased() |
||||
let _firstName = federalData[1].trimmed.capitalized |
||||
if _lastName.isEmpty && _firstName.isEmpty { return nil } |
||||
lastName = _lastName |
||||
firstName = _firstName |
||||
birthdate = federalData[2] |
||||
licenceId = federalData[3] |
||||
clubName = federalData[4] |
||||
let stringRank = federalData[5] |
||||
if stringRank.isEmpty { |
||||
rank = nil |
||||
} else { |
||||
rank = Int(stringRank) |
||||
} |
||||
let _email = federalData[6] |
||||
if _email.isEmpty == false { |
||||
self.email = _email |
||||
} |
||||
let _phoneNumber = federalData[7] |
||||
if _phoneNumber.isEmpty == false { |
||||
self.phoneNumber = _phoneNumber |
||||
} |
||||
|
||||
source = .beachPadel |
||||
if sexUnknown { |
||||
if sex == 1 && FileImportManager.shared.foundInWomenData(license: federalData[3]) { |
||||
self.sex = .female |
||||
} else if FileImportManager.shared.foundInMenData(license: federalData[3]) { |
||||
self.sex = .male |
||||
} else { |
||||
self.sex = nil |
||||
} |
||||
} else { |
||||
self.sex = PlayerSexType(rawValue: sex) |
||||
} |
||||
} |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
if let store = self.store as? TournamentStore { |
||||
return store |
||||
} |
||||
fatalError("missing store for \(String(describing: type(of: self)))") |
||||
} |
||||
|
||||
var computedAge: Int? { |
||||
if let birthdate { |
||||
let components = birthdate.components(separatedBy: "/") |
||||
if components.count == 3 { |
||||
if let year = Calendar.current.dateComponents([.year], from: Date()).year, let age = components.last, let ageInt = Int(age) { |
||||
if age.count == 2 { //si l'année est sur 2 chiffres dans le fichier |
||||
if ageInt < 23 { |
||||
return year - 2000 - ageInt |
||||
} else { |
||||
return year - 2000 + 100 - ageInt |
||||
} |
||||
} else { //si l'année est représenté sur 4 chiffres |
||||
return year - ageInt |
||||
} |
||||
} |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func pasteData(_ exportFormat: ExportFormat = .rawText) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return [firstName.capitalized, lastName.capitalized, licenceId].compactMap({ $0 }).joined(separator: exportFormat.separator()) |
||||
case .csv: |
||||
return [lastName.uppercased() + " " + firstName.capitalized].joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
func isPlaying() -> Bool { |
||||
team()?.isPlaying() == true |
||||
} |
||||
|
||||
func contains(_ searchField: String) -> Bool { |
||||
firstName.localizedCaseInsensitiveContains(searchField) || lastName.localizedCaseInsensitiveContains(searchField) |
||||
} |
||||
|
||||
func isSameAs(_ player: PlayerRegistration) -> Bool { |
||||
firstName.localizedCaseInsensitiveCompare(player.firstName) == .orderedSame && |
||||
lastName.localizedCaseInsensitiveCompare(player.lastName) == .orderedSame |
||||
} |
||||
|
||||
func tournament() -> Tournament? { |
||||
guard let tournament = team()?.tournament else { return nil } |
||||
return Store.main.findById(tournament) |
||||
} |
||||
|
||||
func team() -> TeamRegistration? { |
||||
guard let teamRegistration else { return nil } |
||||
return self.tournamentStore.teamRegistrations.findById(teamRegistration) |
||||
} |
||||
|
||||
func hasPaid() -> Bool { |
||||
paymentType != nil |
||||
} |
||||
|
||||
func playerLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
switch displayStyle { |
||||
case .wide, .title: |
||||
return lastName.trimmed.capitalized + " " + firstName.trimmed.capitalized |
||||
case .short: |
||||
let names = lastName.components(separatedBy: .whitespaces) |
||||
if lastName.components(separatedBy: .whitespaces).count > 1 { |
||||
if let firstLongWord = names.first(where: { $0.count > 3 }) { |
||||
return firstLongWord.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "." |
||||
} |
||||
} |
||||
return lastName.trimmed.capitalized.trunc(length: 10) + " " + firstName.trimmed.prefix(1).capitalized + "." |
||||
} |
||||
} |
||||
|
||||
func isImported() -> Bool { |
||||
source == .beachPadel |
||||
} |
||||
|
||||
func isValidLicenseNumber(year: Int) -> Bool { |
||||
guard let licenceId else { return false } |
||||
guard licenceId.isLicenseNumber else { return false } |
||||
guard licenceId.suffix(6) == "(\(year))" else { return false } |
||||
return true |
||||
} |
||||
|
||||
@objc |
||||
var canonicalName: String { |
||||
playerLabel().folding(options: .diacriticInsensitive, locale: .current).lowercased() |
||||
} |
||||
|
||||
func rankLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
if let rank, rank > 0 { |
||||
if rank != computedRank { |
||||
return computedRank.formatted() + " (" + rank.formatted() + ")" |
||||
} else { |
||||
return rank.formatted() |
||||
} |
||||
} else { |
||||
return "non classé" + (isMalePlayer() ? "" : "e") |
||||
} |
||||
} |
||||
|
||||
func getRank() -> Int { |
||||
computedRank |
||||
} |
||||
|
||||
@MainActor |
||||
func updateRank(from sources: [CSVParser], lastRank: Int) async throws { |
||||
if let dataFound = try await history(from: sources) { |
||||
rank = dataFound.rankValue?.toInt() |
||||
points = dataFound.points |
||||
tournamentPlayed = dataFound.tournamentCountValue?.toInt() |
||||
} else { |
||||
rank = lastRank |
||||
} |
||||
} |
||||
|
||||
func history(from sources: [CSVParser]) async throws -> Line? { |
||||
guard let license = licenceId?.strippedLicense else { |
||||
return try await historyFromName(from: sources) |
||||
} |
||||
|
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in sources.filter({ $0.maleData == isMalePlayer() }) { |
||||
group.addTask { |
||||
guard !Task.isCancelled else { print("Cancelled"); return nil } |
||||
|
||||
return try? await source.first(where: { line in |
||||
line.rawValue.contains(";\(license);") |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if let first = await group.first(where: { $0 != nil }) { |
||||
group.cancelAll() |
||||
return first |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func historyFromName(from sources: [CSVParser]) async throws -> Line? { |
||||
return await withTaskGroup(of: Line?.self) { group in |
||||
for source in sources.filter({ $0.maleData == isMalePlayer() }) { |
||||
group.addTask { [lastName, firstName] in |
||||
guard !Task.isCancelled else { print("Cancelled"); return nil } |
||||
|
||||
return try? await source.first(where: { line in |
||||
line.rawValue.canonicalVersionWithPunctuation.contains(";\(lastName.canonicalVersionWithPunctuation);\(firstName.canonicalVersionWithPunctuation);") |
||||
}) |
||||
} |
||||
} |
||||
|
||||
if let first = await group.first(where: { $0 != nil }) { |
||||
group.cancelAll() |
||||
return first |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func setComputedRank(in tournament: Tournament) { |
||||
let currentRank = rank ?? tournament.unrankValue(for: isMalePlayer()) ?? 70_000 |
||||
switch tournament.tournamentCategory { |
||||
case .men: |
||||
computedRank = isMalePlayer() ? currentRank : currentRank + PlayerRegistration.addon(for: currentRank, manMax: tournament.maleUnrankedValue ?? 0, womanMax: tournament.femaleUnrankedValue ?? 0) |
||||
default: |
||||
computedRank = currentRank |
||||
} |
||||
} |
||||
|
||||
func isMalePlayer() -> Bool { |
||||
sex == .male |
||||
} |
||||
|
||||
func validateLicenceId(_ year: Int) { |
||||
if let currentLicenceId = licenceId { |
||||
if currentLicenceId.trimmed.hasSuffix("(\(year-1))") { |
||||
self.licenceId = currentLicenceId.replacingOccurrences(of: "\(year-1)", with: "\(year)") |
||||
} else if let computedLicense = currentLicenceId.strippedLicense { |
||||
self.licenceId = computedLicense + " (\(year))" |
||||
} |
||||
} |
||||
} |
||||
|
||||
func hasHomonym() -> Bool { |
||||
let federalContext = PersistenceController.shared.localContainer.viewContext |
||||
let fetchRequest = ImportedPlayer.fetchRequest() |
||||
let predicate = NSPredicate(format: "firstName == %@ && lastName == %@", firstName, lastName) |
||||
fetchRequest.predicate = predicate |
||||
|
||||
do { |
||||
let count = try federalContext.count(for: fetchRequest) |
||||
return count > 1 |
||||
} catch { |
||||
|
||||
} |
||||
return false |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _teamRegistration = "teamRegistration" |
||||
case _firstName = "firstName" |
||||
case _lastName = "lastName" |
||||
case _licenceId = "licenceId" |
||||
case _rank = "rank" |
||||
case _paymentType = "paymentType" |
||||
case _sex = "sex" |
||||
case _tournamentPlayed = "tournamentPlayed" |
||||
case _points = "points" |
||||
case _clubName = "clubName" |
||||
case _ligueName = "ligueName" |
||||
case _assimilation = "assimilation" |
||||
case _birthdate = "birthdate" |
||||
case _phoneNumber = "phoneNumber" |
||||
case _email = "email" |
||||
case _computedRank = "computedRank" |
||||
case _source = "source" |
||||
case _hasArrived = "hasArrived" |
||||
|
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
|
||||
if let teamRegistration = teamRegistration { |
||||
try container.encode(teamRegistration, forKey: ._teamRegistration) |
||||
} else { |
||||
try container.encodeNil(forKey: ._teamRegistration) |
||||
} |
||||
|
||||
try container.encode(firstName, forKey: ._firstName) |
||||
try container.encode(lastName, forKey: ._lastName) |
||||
|
||||
if let licenceId = licenceId { |
||||
try container.encode(licenceId, forKey: ._licenceId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._licenceId) |
||||
} |
||||
|
||||
if let rank = rank { |
||||
try container.encode(rank, forKey: ._rank) |
||||
} else { |
||||
try container.encodeNil(forKey: ._rank) |
||||
} |
||||
|
||||
if let paymentType = paymentType { |
||||
try container.encode(paymentType, forKey: ._paymentType) |
||||
} else { |
||||
try container.encodeNil(forKey: ._paymentType) |
||||
} |
||||
|
||||
if let sex = sex { |
||||
try container.encode(sex, forKey: ._sex) |
||||
} else { |
||||
try container.encodeNil(forKey: ._sex) |
||||
} |
||||
|
||||
if let tournamentPlayed = tournamentPlayed { |
||||
try container.encode(tournamentPlayed, forKey: ._tournamentPlayed) |
||||
} else { |
||||
try container.encodeNil(forKey: ._tournamentPlayed) |
||||
} |
||||
|
||||
if let points = points { |
||||
try container.encode(points, forKey: ._points) |
||||
} else { |
||||
try container.encodeNil(forKey: ._points) |
||||
} |
||||
|
||||
if let clubName = clubName { |
||||
try container.encode(clubName, forKey: ._clubName) |
||||
} else { |
||||
try container.encodeNil(forKey: ._clubName) |
||||
} |
||||
|
||||
if let ligueName = ligueName { |
||||
try container.encode(ligueName, forKey: ._ligueName) |
||||
} else { |
||||
try container.encodeNil(forKey: ._ligueName) |
||||
} |
||||
|
||||
if let assimilation = assimilation { |
||||
try container.encode(assimilation, forKey: ._assimilation) |
||||
} else { |
||||
try container.encodeNil(forKey: ._assimilation) |
||||
} |
||||
|
||||
if let phoneNumber = phoneNumber { |
||||
try container.encode(phoneNumber, forKey: ._phoneNumber) |
||||
} else { |
||||
try container.encodeNil(forKey: ._phoneNumber) |
||||
} |
||||
|
||||
if let email = email { |
||||
try container.encode(email, forKey: ._email) |
||||
} else { |
||||
try container.encodeNil(forKey: ._email) |
||||
} |
||||
|
||||
if let birthdate = birthdate { |
||||
try container.encode(birthdate, forKey: ._birthdate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._birthdate) |
||||
} |
||||
|
||||
try container.encode(computedRank, forKey: ._computedRank) |
||||
|
||||
if let source = source { |
||||
try container.encode(source, forKey: ._source) |
||||
} else { |
||||
try container.encodeNil(forKey: ._source) |
||||
} |
||||
|
||||
try container.encode(hasArrived, forKey: ._hasArrived) |
||||
} |
||||
|
||||
enum PlayerDataSource: Int, Codable { |
||||
case frenchFederation = 0 |
||||
case beachPadel = 1 |
||||
} |
||||
|
||||
enum PlayerSexType: Int, Hashable, CaseIterable, Identifiable, Codable { |
||||
init?(rawValue: Int?) { |
||||
guard let value = rawValue else { return nil } |
||||
self.init(rawValue: value) |
||||
} |
||||
|
||||
var id: Self { |
||||
self |
||||
} |
||||
|
||||
case female = 0 |
||||
case male = 1 |
||||
} |
||||
|
||||
enum PlayerPaymentType: Int, CaseIterable, Identifiable, Codable { |
||||
init?(rawValue: Int?) { |
||||
guard let value = rawValue else { return nil } |
||||
self.init(rawValue: value) |
||||
} |
||||
|
||||
var id: Self { |
||||
self |
||||
} |
||||
|
||||
case cash = 0 |
||||
case lydia = 1 |
||||
case gift = 2 |
||||
case check = 3 |
||||
case paylib = 4 |
||||
case bankTransfer = 5 |
||||
case clubHouse = 6 |
||||
case creditCard = 7 |
||||
case forfeit = 8 |
||||
|
||||
func localizedLabel(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
switch self { |
||||
case .check: |
||||
return "Chèque" |
||||
case .cash: |
||||
return "Cash" |
||||
case .lydia: |
||||
return "Lydia" |
||||
case .paylib: |
||||
return "Paylib" |
||||
case .bankTransfer: |
||||
return "Virement" |
||||
case .clubHouse: |
||||
return "Clubhouse" |
||||
case .creditCard: |
||||
return "CB" |
||||
case .forfeit: |
||||
return "Forfait" |
||||
case .gift: |
||||
return "Offert" |
||||
} |
||||
} |
||||
} |
||||
|
||||
static func addon(for playerRank: Int, manMax: Int, womanMax: Int) -> Int { |
||||
switch playerRank { |
||||
case 0: return 0 |
||||
case womanMax: return manMax - womanMax |
||||
case manMax: return 0 |
||||
case 1...10: return 400 |
||||
case 11...30: return 1000 |
||||
case 31...60: return 2000 |
||||
case 61...100: return 3000 |
||||
case 101...200: return 8000 |
||||
case 201...500: return 12000 |
||||
default: |
||||
return 15000 |
||||
} |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.playerRegistrations.writeChangeAndInsertOnServer(instance: self) |
||||
} |
||||
|
||||
} |
||||
|
||||
extension PlayerRegistration: Hashable { |
||||
static func == (lhs: PlayerRegistration, rhs: PlayerRegistration) -> Bool { |
||||
lhs.id == rhs.id |
||||
} |
||||
|
||||
func hash(into hasher: inout Hasher) { |
||||
hasher.combine(id) |
||||
} |
||||
} |
||||
|
||||
extension PlayerRegistration: PlayerHolder { |
||||
func getAssimilatedAsMaleRank() -> Int? { |
||||
nil |
||||
} |
||||
|
||||
func getFirstName() -> String { |
||||
firstName |
||||
} |
||||
|
||||
func getLastName() -> String { |
||||
lastName |
||||
} |
||||
|
||||
func getPoints() -> Double? { |
||||
self.points |
||||
} |
||||
|
||||
func getRank() -> Int? { |
||||
rank |
||||
} |
||||
|
||||
func isUnranked() -> Bool { |
||||
rank == nil |
||||
} |
||||
|
||||
func formattedRank() -> String { |
||||
self.rankLabel() |
||||
} |
||||
|
||||
func formattedLicense() -> String { |
||||
if let licenceId { return licenceId.computedLicense } |
||||
return "aucune licence" |
||||
} |
||||
|
||||
var male: Bool { |
||||
isMalePlayer() |
||||
} |
||||
} |
||||
@ -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 |
||||
- Ouvrir **ServerDataTests** et ajouter un test sur le champ |
||||
- Pour que les tests sur les dates fonctionnent, on peut tester date.formatted() par exemple |
||||
|
||||
Dans Django: |
||||
- Ajouter le champ dans la classe |
||||
- S'il c'est un champ dans **CustomUser**: |
||||
- Ajouter le champ à la méthode fields_for_update |
||||
- Ajouter le champ dans UserSerializer > create > create_user dans serializers.py |
||||
- L'ajouter aussi dans admin.py si nécéssaire |
||||
- Faire le *makemigrations* + *migrate* |
||||
|
||||
|
||||
Enfin, revenir dans Xcode, ouvrir ServerDataTests et lancer le test mis à jour |
||||
|
||||
@ -1,755 +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? |
||||
|
||||
internal init(tournament: String, index: Int, parent: String? = nil, matchFormat: MatchFormat? = nil, startDate: Date? = nil) { |
||||
self.tournament = tournament |
||||
self.index = index |
||||
self.parent = parent |
||||
self.format = matchFormat |
||||
self.startDate = startDate |
||||
} |
||||
|
||||
// MARK: - Computed dependencies |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
return TournamentStore.instance(tournamentId: self.tournament) |
||||
} |
||||
|
||||
func tournamentObject() -> Tournament? { |
||||
return Store.main.findById(tournament) |
||||
} |
||||
|
||||
func _matches() -> [Match] { |
||||
return self.tournamentStore.matches.filter { $0.round == self.id }.sorted(by: \.index) |
||||
// return Store.main.filter { $0.round == self.id } |
||||
} |
||||
|
||||
func getDisabledMatches() -> [Match] { |
||||
return self.tournamentStore.matches.filter { $0.round == self.id && $0.disabled == true } |
||||
// return Store.main.filter { $0.round == self.id && $0.disabled == true } |
||||
} |
||||
|
||||
// MARK: - |
||||
|
||||
var matchFormat: MatchFormat { |
||||
get { |
||||
format ?? .defaultFormatForMatchType(.bracket) |
||||
} |
||||
set { |
||||
format = newValue |
||||
} |
||||
} |
||||
|
||||
func hasStarted() -> Bool { |
||||
return playedMatches().anySatisfy({ $0.hasStarted() }) |
||||
} |
||||
|
||||
func hasEnded() -> Bool { |
||||
if parent == nil { |
||||
return playedMatches().anySatisfy({ $0.hasEnded() == false }) == false |
||||
} else { |
||||
return enabledMatches().anySatisfy({ $0.hasEnded() == false }) == false |
||||
} |
||||
} |
||||
|
||||
func upperMatches(ofMatch match: Match) -> [Match] { |
||||
if parent != nil, previousRound() == nil, let parentRound { |
||||
let matchIndex = match.index |
||||
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) |
||||
return [parentRound.getMatch(atMatchIndexInRound: indexInRound * 2), parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1)].compactMap({ $0 }) |
||||
} |
||||
return [] |
||||
} |
||||
|
||||
func previousMatches(ofMatch match: Match) -> [Match] { |
||||
guard let previousRound = previousRound() else { return [] } |
||||
|
||||
return self.tournamentStore.matches.filter { |
||||
$0.round == previousRound.id && ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex()) |
||||
} |
||||
|
||||
// return Store.main.filter { |
||||
// ($0.index == match.topPreviousRoundMatchIndex() || $0.index == match.bottomPreviousRoundMatchIndex()) && $0.round == previousRound.id |
||||
// } |
||||
} |
||||
|
||||
func precedentMatches(ofMatch match: Match) -> [Match] { |
||||
let upper = upperMatches(ofMatch: match) |
||||
if upper.isEmpty == false { |
||||
return upper |
||||
} |
||||
let previous : [Match] = previousMatches(ofMatch: match) |
||||
if previous.isEmpty == false && previous.allSatisfy({ $0.disabled }), let previousRound = previousRound() { |
||||
return previous.flatMap({ previousRound.precedentMatches(ofMatch: $0) }) |
||||
} else { |
||||
return previous |
||||
} |
||||
} |
||||
|
||||
func team(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? { |
||||
return roundProjectedTeam(team, inMatch: match, previousRound: previousRound) |
||||
} |
||||
|
||||
func seed(_ team: TeamPosition, inMatchIndex matchIndex: Int) -> TeamRegistration? { |
||||
return self.tournamentStore.teamRegistrations.first(where: { |
||||
$0.tournament == tournament |
||||
&& $0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) == matchIndex |
||||
&& ($0.bracketPosition! % 2) == team.rawValue |
||||
}) |
||||
} |
||||
|
||||
func seeds(inMatchIndex matchIndex: Int) -> [TeamRegistration] { |
||||
return self.tournamentStore.teamRegistrations.filter { |
||||
|
||||
$0.tournament == tournament |
||||
&& $0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) == matchIndex |
||||
} |
||||
|
||||
// return Store.main.filter(isIncluded: { |
||||
// $0.tournament == tournament |
||||
// && $0.bracketPosition != nil |
||||
// && ($0.bracketPosition! / 2) == matchIndex |
||||
// }) |
||||
} |
||||
|
||||
func seeds() -> [TeamRegistration] { |
||||
let initialMatchIndex = RoundRule.matchIndex(fromRoundIndex: index) |
||||
let numberOfMatches = RoundRule.numberOfMatches(forRoundIndex: index) |
||||
return self.tournamentStore.teamRegistrations.filter { |
||||
$0.tournament == tournament |
||||
&& $0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) >= initialMatchIndex |
||||
&& ($0.bracketPosition! / 2) < initialMatchIndex + numberOfMatches |
||||
} |
||||
} |
||||
|
||||
func losers() -> [TeamRegistration] { |
||||
let teamIds: [String] = self._matches().compactMap { $0.losingTeamId } |
||||
return teamIds.compactMap { self.tournamentStore.teamRegistrations.findById($0) } |
||||
} |
||||
|
||||
func teams() -> [TeamRegistration] { |
||||
return playedMatches().flatMap({ $0.teams() }) |
||||
} |
||||
|
||||
func roundProjectedTeam(_ team: TeamPosition, inMatch match: Match, previousRound: Round?) -> TeamRegistration? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func roundProjectedTeam", team.rawValue, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
if isLoserBracket() == false, let seed = seed(team, inMatchIndex: match.index) { |
||||
return seed |
||||
} |
||||
|
||||
switch team { |
||||
case .one: |
||||
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 }) { |
||||
return luckyLoser.team |
||||
} else if let previousMatch = topPreviousRoundMatch(ofMatch: match, previousRound: previousRound) { |
||||
if let teamId = previousMatch.winningTeamId { |
||||
return self.tournamentStore.teamRegistrations.findById(teamId) |
||||
} else if previousMatch.disabled { |
||||
return previousMatch.teams().first |
||||
} |
||||
} else if let parent = upperBracketTopMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId { |
||||
return tournamentStore.findById(parent) |
||||
} |
||||
case .two: |
||||
if let luckyLoser = match.teamScores.first(where: { $0.luckyLoser == match.index * 2 + 1 }) { |
||||
return luckyLoser.team |
||||
} else if let previousMatch = bottomPreviousRoundMatch(ofMatch: match, previousRound: previousRound) { |
||||
if let teamId = previousMatch.winningTeamId { |
||||
return self.tournamentStore.teamRegistrations.findById(teamId) |
||||
} else if previousMatch.disabled { |
||||
return previousMatch.teams().first |
||||
} |
||||
} else if let parent = upperBracketBottomMatch(ofMatchIndex: match.index, previousRound: previousRound)?.losingTeamId { |
||||
return tournamentStore.findById(parent) |
||||
} |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func upperBracketTopMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func upperBracketTopMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) |
||||
if isLoserBracket(), previousRound == nil, let parentRound = parentRound, let upperBracketTopMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2) { |
||||
return upperBracketTopMatch |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func upperBracketBottomMatch(ofMatchIndex matchIndex: Int, previousRound: Round?) -> Match? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func upperBracketBottomMatch", matchIndex, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
let indexInRound = RoundRule.matchIndexWithinRound(fromMatchIndex: matchIndex) |
||||
if isLoserBracket(), previousRound == nil, let parentRound = parentRound, let upperBracketBottomMatch = parentRound.getMatch(atMatchIndexInRound: indexInRound * 2 + 1) { |
||||
return upperBracketBottomMatch |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
|
||||
func topPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func topPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
guard let previousRound else { return nil } |
||||
let topPreviousRoundMatchIndex = match.topPreviousRoundMatchIndex() |
||||
return self.tournamentStore.matches.first(where: { |
||||
$0.round == previousRound.id && $0.index == topPreviousRoundMatchIndex |
||||
}) |
||||
} |
||||
|
||||
func bottomPreviousRoundMatch(ofMatch match: Match, previousRound: Round?) -> Match? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func bottomPreviousRoundMatch", match.id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
guard let previousRound else { return nil } |
||||
let bottomPreviousRoundMatchIndex = match.bottomPreviousRoundMatchIndex() |
||||
return self.tournamentStore.matches.first(where: { |
||||
$0.round == previousRound.id && $0.index == bottomPreviousRoundMatchIndex |
||||
}) |
||||
} |
||||
|
||||
func getMatch(atMatchIndexInRound matchIndexInRound: Int) -> Match? { |
||||
self.tournamentStore.matches.first(where: { |
||||
let index = RoundRule.matchIndexWithinRound(fromMatchIndex: $0.index) |
||||
return $0.round == id && index == matchIndexInRound |
||||
}) |
||||
} |
||||
|
||||
func enabledMatches() -> [Match] { |
||||
return self.tournamentStore.matches.filter { $0.round == self.id && $0.disabled == false }.sorted(by: \.index) |
||||
} |
||||
|
||||
// func displayableMatches() -> [Match] { |
||||
//#if _DEBUG_TIME //DEBUGING TIME |
||||
// let start = Date() |
||||
// defer { |
||||
// let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
// print("func displayableMatches of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
// } |
||||
//#endif |
||||
// |
||||
// if index == 0 && isUpperBracket() { |
||||
// var matches : [Match?] = [playedMatches().first] |
||||
// matches.append(loserRounds().first?.playedMatches().first) |
||||
// return matches.compactMap({ $0 }) |
||||
// } else { |
||||
// return playedMatches() |
||||
// } |
||||
// } |
||||
|
||||
func playedMatches() -> [Match] { |
||||
if parent == nil { |
||||
return enabledMatches() |
||||
} else { |
||||
return _matches() |
||||
} |
||||
} |
||||
|
||||
func previousRound() -> Round? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func previousRound of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return self.tournamentStore.rounds.first(where: { $0.parent == parent && $0.index == index + 1 }) |
||||
} |
||||
|
||||
func nextRound() -> Round? { |
||||
return self.tournamentStore.rounds.first(where: { $0.parent == parent && $0.index == index - 1 }) |
||||
} |
||||
|
||||
func loserRounds(forRoundIndex roundIndex: Int) -> [Round] { |
||||
return loserRoundsAndChildren().filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount) |
||||
} |
||||
|
||||
func loserRounds(forRoundIndex roundIndex: Int, loserRoundsAndChildren: [Round]) -> [Round] { |
||||
return loserRoundsAndChildren.filter({ $0.index == roundIndex }).sorted(by: \.theoryCumulativeMatchCount) |
||||
} |
||||
|
||||
func isDisabled() -> Bool { |
||||
return _matches().allSatisfy({ $0.disabled }) |
||||
} |
||||
|
||||
func isRankDisabled() -> Bool { |
||||
return _matches().allSatisfy({ $0.disabled && $0.teamScores.isEmpty }) |
||||
} |
||||
|
||||
func resetFromRoundAllMatchesStartDate() { |
||||
_matches().forEach({ |
||||
$0.startDate = nil |
||||
}) |
||||
loserRoundsAndChildren().forEach { round in |
||||
round.resetFromRoundAllMatchesStartDate() |
||||
} |
||||
nextRound()?.resetFromRoundAllMatchesStartDate() |
||||
} |
||||
|
||||
func resetFromRoundAllMatchesStartDate(from match: Match) { |
||||
let matches = _matches() |
||||
if let index = matches.firstIndex(where: { $0.id == match.id }) { |
||||
matches[index...].forEach { match in |
||||
match.startDate = nil |
||||
} |
||||
} |
||||
loserRoundsAndChildren().forEach { round in |
||||
round.resetFromRoundAllMatchesStartDate() |
||||
} |
||||
nextRound()?.resetFromRoundAllMatchesStartDate() |
||||
} |
||||
|
||||
func getActiveLoserRound() -> Round? { |
||||
let rounds = loserRounds().filter({ $0.isDisabled() == false }).sorted(by: \.index).reversed() |
||||
return rounds.first(where: { $0.hasStarted() && $0.hasEnded() == false }) ?? rounds.first |
||||
} |
||||
|
||||
func enableRound() { |
||||
_toggleRound(disable: false) |
||||
} |
||||
|
||||
func disableRound() { |
||||
_toggleRound(disable: true) |
||||
} |
||||
|
||||
private func _toggleRound(disable: Bool) { |
||||
let _matches = _matches() |
||||
_matches.forEach { match in |
||||
match.disabled = disable |
||||
match.resetMatch() |
||||
//we need to keep teamscores to handle disable ranking match round stuff |
||||
// do { |
||||
// try DataStore.shared.teamScores.delete(contentOfs: match.teamScores) |
||||
// } catch { |
||||
// Logger.error(error) |
||||
// } |
||||
} |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: _matches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
var cumulativeMatchCount: Int { |
||||
var totalMatches = playedMatches().count |
||||
if let parentRound { |
||||
totalMatches += parentRound.cumulativeMatchCount |
||||
} |
||||
return totalMatches |
||||
} |
||||
|
||||
func initialRound() -> Round? { |
||||
if let parentRound { |
||||
return parentRound.initialRound() |
||||
} else { |
||||
return self |
||||
} |
||||
} |
||||
|
||||
func estimatedEndDate(_ additionalEstimationDuration: Int) -> Date? { |
||||
return enabledMatches().last?.estimatedEndDate(additionalEstimationDuration) |
||||
} |
||||
|
||||
func getLoserRoundStartDate() -> Date? { |
||||
return loserRoundsAndChildren().first(where: { $0.isDisabled() == false })?.enabledMatches().first?.startDate |
||||
} |
||||
|
||||
func estimatedLoserRoundEndDate(_ additionalEstimationDuration: Int) -> Date? { |
||||
let lastMatch = loserRoundsAndChildren().last(where: { $0.isDisabled() == false })?.enabledMatches().last |
||||
return lastMatch?.estimatedEndDate(additionalEstimationDuration) |
||||
} |
||||
|
||||
func disabledMatches() -> [Match] { |
||||
return _matches().filter({ $0.disabled }) |
||||
} |
||||
|
||||
var theoryCumulativeMatchCount: Int { |
||||
var totalMatches = RoundRule.numberOfMatches(forRoundIndex: index) |
||||
if let parentRound { |
||||
totalMatches += parentRound.theoryCumulativeMatchCount |
||||
} |
||||
return totalMatches |
||||
} |
||||
|
||||
|
||||
func correspondingLoserRoundTitle(_ displayStyle: DisplayStyle = .wide) -> String { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func correspondingLoserRoundTitle()", duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index) |
||||
let seedsAfterThisRound: [TeamRegistration] = self.tournamentStore.teamRegistrations.filter { |
||||
$0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex |
||||
} |
||||
|
||||
// let seedsAfterThisRound : [TeamRegistration] = Store.main.filter(isIncluded: { |
||||
// $0.tournament == tournament |
||||
// && $0.bracketPosition != nil |
||||
// && ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex |
||||
// }) |
||||
let playedMatches = playedMatches() |
||||
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count) |
||||
return seedInterval.localizedLabel(displayStyle) |
||||
} |
||||
|
||||
func hasNextRound() -> Bool { |
||||
return nextRound()?.isRankDisabled() == false |
||||
} |
||||
|
||||
func pasteData() -> String { |
||||
var data: [String] = [] |
||||
data.append(self.roundTitle()) |
||||
|
||||
playedMatches().forEach { match in |
||||
data.append(match.matchTitle()) |
||||
data.append(match.team(.one)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----") |
||||
data.append(match.team(.two)?.teamLabelRanked(displayRank: true, displayTeamName: true) ?? "-----") |
||||
} |
||||
|
||||
return data.joined(separator: "\n") |
||||
} |
||||
|
||||
func seedInterval(initialMode: Bool = false) -> SeedInterval? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func seedInterval(initialMode)", initialMode, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
if parent == nil { |
||||
if index == 0 { return SeedInterval(first: 1, last: 2) } |
||||
let initialMatchIndexFromRoundIndex = RoundRule.matchIndex(fromRoundIndex: index) |
||||
let seedsAfterThisRound : [TeamRegistration] = self.tournamentStore.teamRegistrations.filter { |
||||
$0.bracketPosition != nil |
||||
&& ($0.bracketPosition! / 2) < initialMatchIndexFromRoundIndex |
||||
} |
||||
let playedMatches = playedMatches() |
||||
let seedInterval = SeedInterval(first: playedMatches.count + seedsAfterThisRound.count + 1, last: playedMatches.count * 2 + seedsAfterThisRound.count) |
||||
return seedInterval |
||||
} |
||||
|
||||
if let previousRound = previousRound() { |
||||
if (previousRound.enabledMatches().isEmpty == false || initialMode) { |
||||
return previousRound.seedInterval(initialMode: initialMode)?.chunks()?.first |
||||
} else { |
||||
return previousRound.seedInterval(initialMode: initialMode) |
||||
} |
||||
} else if let parentRound { |
||||
if parentRound.parent == nil { |
||||
return parentRound.seedInterval(initialMode: initialMode) |
||||
} |
||||
return parentRound.seedInterval(initialMode: initialMode)?.chunks()?.last |
||||
} |
||||
|
||||
return nil |
||||
} |
||||
|
||||
func roundTitle(_ displayStyle: DisplayStyle = .wide, initialMode: Bool = false) -> String { |
||||
if parent != nil { |
||||
if let seedInterval = seedInterval(initialMode: initialMode) { |
||||
return seedInterval.localizedLabel(displayStyle) |
||||
} |
||||
print("Round pas trouvé", id, parent, index) |
||||
return "Match de classement" |
||||
} |
||||
return RoundRule.roundName(fromRoundIndex: index, displayStyle: displayStyle) |
||||
} |
||||
|
||||
func updateTournamentState() { |
||||
if let tournamentObject = tournamentObject(), index == 0, isUpperBracket(), hasEnded() { |
||||
tournamentObject.endDate = Date() |
||||
do { |
||||
try DataStore.shared.tournaments.addOrUpdate(instance: tournamentObject) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func roundStatus() -> String { |
||||
let hasEnded = hasEnded() |
||||
if hasStarted() && hasEnded == false { |
||||
return "en cours" |
||||
} else if hasEnded { |
||||
return "terminée" |
||||
} else { |
||||
return "à démarrer" |
||||
} |
||||
} |
||||
|
||||
func loserRounds() -> [Round] { |
||||
|
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func loserRounds: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
return self.tournamentStore.rounds.filter( { $0.parent == id }).sorted(by: \.index).reversed() |
||||
} |
||||
|
||||
func loserRoundsAndChildren() -> [Round] { |
||||
let loserRounds = loserRounds() |
||||
return loserRounds + loserRounds.flatMap({ $0.loserRoundsAndChildren() }) |
||||
} |
||||
|
||||
func isUpperBracket() -> Bool { |
||||
return parent == nil |
||||
} |
||||
|
||||
func isLoserBracket() -> Bool { |
||||
return parent != nil |
||||
} |
||||
|
||||
func deleteLoserBracket() { |
||||
do { |
||||
try self.tournamentStore.rounds.delete(contentOfs: loserRounds()) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func buildLoserBracket() { |
||||
guard loserRounds().isEmpty else { return } |
||||
let currentRoundMatchCount = RoundRule.numberOfMatches(forRoundIndex: index) |
||||
guard currentRoundMatchCount > 1 else { return } |
||||
let roundCount = RoundRule.numberOfRounds(forTeams: currentRoundMatchCount) |
||||
|
||||
var loserBracketMatchFormat = tournamentObject()?.loserBracketMatchFormat |
||||
if let parentRound { |
||||
loserBracketMatchFormat = tournamentObject()?.loserBracketSmartMatchFormat(parentRound.index) |
||||
} |
||||
|
||||
let rounds = (0..<roundCount).map { //index 0 is the final |
||||
let round = Round(tournament: tournament, index: $0, matchFormat: loserBracketMatchFormat) |
||||
round.parent = id //parent |
||||
return round |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.rounds.addOrUpdate(contentOfs: rounds) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
let matchCount = RoundRule.numberOfMatches(forTeams: currentRoundMatchCount) |
||||
|
||||
let matches = (0..<matchCount).map { //0 is final match |
||||
let roundIndex = RoundRule.roundIndex(fromMatchIndex: $0) |
||||
let round = rounds[roundIndex] |
||||
return Match(round: round.id, index: $0, matchFormat: loserBracketMatchFormat, name: round.roundTitle(initialMode: true)) |
||||
//initial mode let the roundTitle give a name without considering the playable match |
||||
} |
||||
|
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: matches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
loserRounds().forEach { round in |
||||
round.buildLoserBracket() |
||||
} |
||||
} |
||||
|
||||
var parentRound: Round? { |
||||
guard let parent = parent else { return nil } |
||||
return self.tournamentStore.rounds.findById(parent) |
||||
} |
||||
|
||||
func updateMatchFormat(_ updatedMatchFormat: MatchFormat, checkIfPossible: Bool, andLoserBracket: Bool) { |
||||
if updatedMatchFormat.weight < self.matchFormat.weight { |
||||
updateMatchFormatAndAllMatches(updatedMatchFormat) |
||||
if andLoserBracket { |
||||
loserRoundsAndChildren().forEach { round in |
||||
round.updateMatchFormat(updatedMatchFormat, checkIfPossible: checkIfPossible, andLoserBracket: true) |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
func updateMatchFormatAndAllMatches(_ updatedMatchFormat: MatchFormat) { |
||||
self.matchFormat = updatedMatchFormat |
||||
self.updateMatchFormatOfAllMatches(updatedMatchFormat) |
||||
} |
||||
|
||||
func updateMatchFormatOfAllMatches(_ updatedMatchFormat: MatchFormat) { |
||||
let playedMatches = _matches() |
||||
playedMatches.forEach { match in |
||||
match.matchFormat = updatedMatchFormat |
||||
} |
||||
do { |
||||
try self.tournamentStore.matches.addOrUpdate(contentOfs: playedMatches) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
override func deleteDependencies() throws { |
||||
let matches = self._matches() |
||||
for match in matches { |
||||
try match.deleteDependencies() |
||||
} |
||||
|
||||
self.tournamentStore.matches.deleteDependencies(matches) |
||||
|
||||
let loserRounds = self.loserRounds() |
||||
for round in loserRounds { |
||||
try round.deleteDependencies() |
||||
} |
||||
|
||||
self.tournamentStore.rounds.deleteDependencies(loserRounds) |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _tournament = "tournament" |
||||
case _index = "index" |
||||
case _parent = "parent" |
||||
case _format = "format" |
||||
case _startDate = "startDate" |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
try container.encode(tournament, forKey: ._tournament) |
||||
try container.encode(index, forKey: ._index) |
||||
|
||||
if let parent = parent { |
||||
try container.encode(parent, forKey: ._parent) |
||||
} else { |
||||
try container.encodeNil(forKey: ._parent) |
||||
} |
||||
|
||||
if let format = format { |
||||
try container.encode(format, forKey: ._format) |
||||
} else { |
||||
try container.encodeNil(forKey: ._format) |
||||
} |
||||
|
||||
if let startDate = startDate { |
||||
try container.encode(startDate, forKey: ._startDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._startDate) |
||||
} |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.rounds.writeChangeAndInsertOnServer(instance: self) |
||||
for match in self._matches() { |
||||
match.insertOnServer() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension Round: Selectable, Equatable { |
||||
static func == (lhs: Round, rhs: Round) -> Bool { |
||||
lhs.id == rhs.id |
||||
} |
||||
|
||||
|
||||
func selectionLabel(index: Int) -> String { |
||||
if let parentRound { |
||||
return "Tour #\(parentRound.loserRounds().count - index)" |
||||
} else { |
||||
return roundTitle(.short) |
||||
} |
||||
} |
||||
|
||||
func badgeValue() -> Int? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func badgeValue round of: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
|
||||
|
||||
if let parentRound { |
||||
return parentRound.loserRounds(forRoundIndex: index).flatMap { $0.playedMatches() }.filter({ $0.isRunning() }).count |
||||
} else { |
||||
return playedMatches().filter({ $0.isRunning() }).count |
||||
} |
||||
} |
||||
|
||||
func badgeValueColor() -> Color? { |
||||
return nil |
||||
} |
||||
|
||||
func badgeImage() -> Badge? { |
||||
#if _DEBUG_TIME //DEBUGING TIME |
||||
let start = Date() |
||||
defer { |
||||
let duration = Duration.milliseconds(Date().timeIntervalSince(start) * 1_000) |
||||
print("func badgeImage of round: ", id, duration.formatted(.units(allowed: [.seconds, .milliseconds]))) |
||||
} |
||||
#endif |
||||
return hasEnded() ? .checkmark : nil |
||||
} |
||||
} |
||||
@ -1,649 +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? |
||||
|
||||
init(tournament: String, groupStage: String? = nil, registrationDate: Date? = nil, callDate: Date? = nil, bracketPosition: Int? = nil, groupStagePosition: Int? = nil, comment: String? = nil, source: String? = nil, sourceValue: String? = nil, logo: String? = nil, name: String? = nil, walkOut: Bool = false, wildCardBracket: Bool = false, wildCardGroupStage: Bool = false, weight: Int = 0, lockedWeight: Int? = nil, confirmationDate: Date? = nil, qualified: Bool = false) { |
||||
self.tournament = tournament |
||||
self.groupStage = groupStage |
||||
self.registrationDate = registrationDate |
||||
self.callDate = callDate |
||||
self.bracketPosition = bracketPosition |
||||
self.groupStagePosition = groupStagePosition |
||||
self.comment = comment |
||||
self.source = source |
||||
self.sourceValue = sourceValue |
||||
self.logo = logo |
||||
self.name = name |
||||
self.walkOut = walkOut |
||||
self.wildCardBracket = wildCardBracket |
||||
self.wildCardGroupStage = wildCardGroupStage |
||||
self.weight = weight |
||||
self.lockedWeight = lockedWeight |
||||
self.confirmationDate = confirmationDate |
||||
self.qualified = qualified |
||||
} |
||||
|
||||
var tournamentStore: TournamentStore { |
||||
return TournamentStore.instance(tournamentId: self.tournament) |
||||
} |
||||
|
||||
// MARK: - Computed dependencies |
||||
|
||||
func unsortedPlayers() -> [PlayerRegistration] { |
||||
return self.tournamentStore.playerRegistrations.filter { $0.teamRegistration == self.id } |
||||
} |
||||
|
||||
// MARK: - |
||||
|
||||
func deleteTeamScores() { |
||||
let ts = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id }) |
||||
do { |
||||
try self.tournamentStore.teamScores.delete(contentOfs: ts) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
override func deleteDependencies() throws { |
||||
let unsortedPlayers = unsortedPlayers() |
||||
for player in unsortedPlayers { |
||||
try player.deleteDependencies() |
||||
} |
||||
self.tournamentStore.playerRegistrations.deleteDependencies(unsortedPlayers) |
||||
|
||||
let teamScores = teamScores() |
||||
for teamScore in teamScores { |
||||
try teamScore.deleteDependencies() |
||||
} |
||||
self.tournamentStore.teamScores.deleteDependencies(teamScores) |
||||
} |
||||
|
||||
func hasArrived(isHere: Bool = false) { |
||||
let unsortedPlayers = unsortedPlayers() |
||||
unsortedPlayers.forEach({ $0.hasArrived = !isHere }) |
||||
do { |
||||
try self.tournamentStore.playerRegistrations.addOrUpdate(contentOfs: unsortedPlayers) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
func isHere() -> Bool { |
||||
let unsortedPlayers = unsortedPlayers() |
||||
return unsortedPlayers.allSatisfy({ $0.hasArrived }) |
||||
} |
||||
|
||||
|
||||
func isSeedable() -> Bool { |
||||
bracketPosition == nil && groupStage == nil |
||||
} |
||||
|
||||
func setSeedPosition(inSpot match: Match, slot: TeamPosition?, opposingSeeding: Bool) { |
||||
let seedPosition: Int = match.lockAndGetSeedPosition(atTeamPosition: slot, opposingSeeding: opposingSeeding) |
||||
tournamentObject()?.resetTeamScores(in: bracketPosition) |
||||
self.bracketPosition = seedPosition |
||||
if groupStagePosition != nil && qualified == false { |
||||
qualified = true |
||||
} |
||||
tournamentObject()?.updateTeamScores(in: bracketPosition) |
||||
} |
||||
|
||||
func expectedSummonDate() -> Date? { |
||||
if let groupStageStartDate = groupStageObject()?.startDate { |
||||
return groupStageStartDate |
||||
} else if let roundMatchStartDate = initialMatch()?.startDate { |
||||
return roundMatchStartDate |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
var initialWeight: Int { |
||||
return lockedWeight ?? weight |
||||
} |
||||
|
||||
func called() -> Bool { |
||||
return callDate != nil |
||||
} |
||||
|
||||
func confirmed() -> Bool { |
||||
return confirmationDate != nil |
||||
} |
||||
|
||||
func getPhoneNumbers() -> [String] { |
||||
return players().compactMap { $0.phoneNumber }.filter({ $0.isMobileNumber() }) |
||||
} |
||||
|
||||
func getMail() -> [String] { |
||||
let mails = players().compactMap({ $0.email }) |
||||
return mails |
||||
} |
||||
|
||||
func isImported() -> Bool { |
||||
return unsortedPlayers().allSatisfy({ $0.isImported() }) |
||||
} |
||||
|
||||
func isWildCard() -> Bool { |
||||
return wildCardBracket || wildCardGroupStage |
||||
} |
||||
|
||||
func isPlaying() -> Bool { |
||||
return currentMatch() != nil |
||||
} |
||||
|
||||
func currentMatch() -> Match? { |
||||
return teamScores().compactMap { $0.matchObject() }.first(where: { $0.isRunning() }) |
||||
} |
||||
|
||||
func teamScores() -> [TeamScore] { |
||||
return self.tournamentStore.teamScores.filter({ $0.teamRegistration == id }) |
||||
} |
||||
|
||||
func wins() -> [Match] { |
||||
return self.tournamentStore.matches.filter({ $0.winningTeamId == id }) |
||||
} |
||||
|
||||
func loses() -> [Match] { |
||||
return self.tournamentStore.matches.filter({ $0.losingTeamId == id }) |
||||
} |
||||
|
||||
func matches() -> [Match] { |
||||
return self.tournamentStore.matches.filter({ $0.losingTeamId == id || $0.winningTeamId == id }) |
||||
} |
||||
|
||||
var tournamentCategory: TournamentCategory { |
||||
tournamentObject()?.tournamentCategory ?? .men |
||||
} |
||||
|
||||
@objc |
||||
var canonicalName: String { |
||||
players().map { $0.canonicalName }.joined(separator: " ") |
||||
} |
||||
|
||||
func hasMemberOfClub(_ codeClubOrClubName: String?) -> Bool { |
||||
guard let codeClubOrClubName else { return true } |
||||
return unsortedPlayers().anySatisfy({ |
||||
$0.clubName?.contains(codeClubOrClubName) == true || $0.clubName?.contains(codeClubOrClubName) == true |
||||
}) |
||||
} |
||||
|
||||
func updateWeight(inTournamentCategory tournamentCategory: TournamentCategory) { |
||||
self.setWeight(from: self.players(), inTournamentCategory: tournamentCategory) |
||||
} |
||||
|
||||
func teamLabel(_ displayStyle: DisplayStyle = .wide, twoLines: Bool = false) -> String { |
||||
return players().map { $0.playerLabel(displayStyle) }.joined(separator: twoLines ? "\n" : " & ") |
||||
} |
||||
|
||||
func teamLabelRanked(displayRank: Bool, displayTeamName: Bool) -> String { |
||||
[displayTeamName ? name : nil, displayRank ? seedIndex() : nil, displayTeamName ? (name == nil ? teamLabel() : name) : teamLabel()].compactMap({ $0 }).joined(separator: " ") |
||||
} |
||||
|
||||
func seedIndex() -> String? { |
||||
guard let tournament = tournamentObject() else { return nil } |
||||
guard let index = index(in: tournament.selectedSortedTeams()) else { return nil } |
||||
return "(\(index + 1))" |
||||
} |
||||
|
||||
func index(in teams: [TeamRegistration]) -> Int? { |
||||
return teams.firstIndex(where: { $0.id == id }) |
||||
} |
||||
|
||||
func formattedSeed(in teams: [TeamRegistration]) -> String { |
||||
if let index = index(in: teams) { |
||||
return "#\(index + 1)" |
||||
} else { |
||||
return "###" |
||||
} |
||||
} |
||||
|
||||
func contains(_ searchField: String) -> Bool { |
||||
return unsortedPlayers().anySatisfy({ $0.contains(searchField) }) || self.name?.localizedCaseInsensitiveContains(searchField) == true |
||||
} |
||||
|
||||
func containsExactlyPlayerLicenses(_ playerLicenses: [String?]) -> Bool { |
||||
let arrayOfIds : [String] = unsortedPlayers().compactMap({ $0.licenceId?.strippedLicense?.canonicalVersion }) |
||||
let ids : Set<String> = Set<String>(arrayOfIds.sorted()) |
||||
let searchedIds = Set<String>(playerLicenses.compactMap({ $0?.strippedLicense?.canonicalVersion }).sorted()) |
||||
return ids.hashValue == searchedIds.hashValue |
||||
} |
||||
|
||||
func includes(players: [PlayerRegistration]) -> Bool { |
||||
let unsortedPlayers = unsortedPlayers() |
||||
guard players.count == unsortedPlayers.count else { return false } |
||||
return players.allSatisfy { player in |
||||
unsortedPlayers.anySatisfy { _player in |
||||
_player.isSameAs(player) |
||||
} |
||||
} |
||||
} |
||||
|
||||
func includes(player: PlayerRegistration) -> Bool { |
||||
return unsortedPlayers().anySatisfy { _player in |
||||
_player.isSameAs(player) |
||||
} |
||||
} |
||||
|
||||
func canPlay() -> Bool { |
||||
return matches().isEmpty == false || players().allSatisfy({ $0.hasPaid() || $0.hasArrived }) |
||||
} |
||||
|
||||
func availableForSeedPick() -> Bool { |
||||
return groupStage == nil && bracketPosition == nil |
||||
} |
||||
|
||||
func inGroupStage() -> Bool { |
||||
return groupStagePosition != nil |
||||
} |
||||
|
||||
func inRound() -> Bool { |
||||
return bracketPosition != nil |
||||
} |
||||
|
||||
func positionLabel() -> String? { |
||||
if groupStagePosition != nil { return "Poule" } |
||||
if let initialRound = initialRound() { |
||||
return initialRound.roundTitle() |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func initialRoundColor() -> Color? { |
||||
if walkOut { return Color.logoRed } |
||||
if groupStagePosition != nil { return Color.blue } |
||||
if let initialRound = initialRound(), let colorHex = RoundRule.colors[safe: initialRound.index] { |
||||
return Color(uiColor: .init(fromHex: colorHex)) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
func resetGroupeStagePosition() { |
||||
if let groupStage { |
||||
let matches = self.tournamentStore.matches.filter({ $0.groupStage == groupStage }).map { $0.id } |
||||
let teamScores = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id && matches.contains($0.match) }) |
||||
do { |
||||
try tournamentStore.teamScores.delete(contentOfs: teamScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
//groupStageObject()?._matches().forEach({ $0.updateTeamScores() }) |
||||
groupStage = nil |
||||
groupStagePosition = nil |
||||
} |
||||
|
||||
func resetBracketPosition() { |
||||
let matches = self.tournamentStore.matches.filter({ $0.groupStage == nil }).map { $0.id } |
||||
let teamScores = self.tournamentStore.teamScores.filter({ $0.teamRegistration == id && matches.contains($0.match) }) |
||||
do { |
||||
try tournamentStore.teamScores.delete(contentOfs: teamScores) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
|
||||
self.bracketPosition = nil |
||||
} |
||||
|
||||
func resetPositions() { |
||||
resetGroupeStagePosition() |
||||
resetBracketPosition() |
||||
} |
||||
|
||||
func pasteData(_ exportFormat: ExportFormat = .rawText, _ index: Int = 0) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return [playersPasteData(exportFormat), formattedInscriptionDate(exportFormat), name].compactMap({ $0 }).joined(separator: exportFormat.newLineSeparator()) |
||||
case .csv: |
||||
return [index.formatted(), playersPasteData(exportFormat), isWildCard() ? "WC" : weight.formatted()].joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
var computedRegistrationDate: Date { |
||||
return registrationDate ?? .distantFuture |
||||
} |
||||
|
||||
func formattedInscriptionDate(_ exportFormat: ExportFormat = .rawText) -> String? { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
if let registrationDate { |
||||
return "Inscrit le " + registrationDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
case .csv: |
||||
if let registrationDate { |
||||
return registrationDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func formattedSummonDate(_ exportFormat: ExportFormat = .rawText) -> String? { |
||||
|
||||
switch exportFormat { |
||||
case .rawText: |
||||
if let callDate { |
||||
return "Convoqué le " + callDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
case .csv: |
||||
if let callDate { |
||||
return callDate.formatted(.dateTime.weekday().day().month().hour().minute()) |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
} |
||||
|
||||
func playersPasteData(_ exportFormat: ExportFormat = .rawText) -> String { |
||||
switch exportFormat { |
||||
case .rawText: |
||||
return players().map { $0.pasteData(exportFormat) }.joined(separator: exportFormat.newLineSeparator()) |
||||
case .csv: |
||||
return players().map { [$0.pasteData(exportFormat), isWildCard() ? "WC" : $0.computedRank.formatted() ].joined(separator: exportFormat.separator()) }.joined(separator: exportFormat.separator()) |
||||
} |
||||
} |
||||
|
||||
func updatePlayers(_ players: Set<PlayerRegistration>, inTournamentCategory tournamentCategory: TournamentCategory) { |
||||
let previousPlayers = Set(unsortedPlayers()) |
||||
let playersToRemove = previousPlayers.subtracting(players) |
||||
do { |
||||
try self.tournamentStore.playerRegistrations.delete(contentOfs: playersToRemove) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
setWeight(from: Array(players), inTournamentCategory: tournamentCategory) |
||||
|
||||
players.forEach { player in |
||||
player.teamRegistration = id |
||||
} |
||||
} |
||||
|
||||
typealias TeamRange = (left: TeamRegistration?, right: TeamRegistration?) |
||||
|
||||
func replacementRange() -> TeamRange? { |
||||
guard let tournamentObject = tournamentObject() else { return nil } |
||||
guard let index = tournamentObject.indexOf(team: self) else { return nil } |
||||
let selectedSortedTeams = tournamentObject.selectedSortedTeams() |
||||
let left = selectedSortedTeams[safe: index - 1] |
||||
let right = selectedSortedTeams[safe: index + 1] |
||||
return (left: left, right: right) |
||||
} |
||||
|
||||
func replacementRangeExtended() -> TeamRange? { |
||||
guard let tournamentObject = tournamentObject() else { return nil } |
||||
guard let groupStagePosition else { return nil } |
||||
let selectedSortedTeams = tournamentObject.selectedSortedTeams() |
||||
var left: TeamRegistration? = nil |
||||
if groupStagePosition == 0 { |
||||
left = tournamentObject.seeds().last |
||||
} else { |
||||
let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition - 1 }).sorted(by: \.weight) |
||||
left = previousHat.last |
||||
} |
||||
var right: TeamRegistration? = nil |
||||
if groupStagePosition == tournamentObject.teamsPerGroupStage - 1 { |
||||
right = nil |
||||
} else { |
||||
let previousHat = selectedSortedTeams.filter({ $0.groupStagePosition == groupStagePosition + 1 }).sorted(by: \.weight) |
||||
right = previousHat.first |
||||
} |
||||
return (left: left, right: right) |
||||
} |
||||
|
||||
typealias AreInIncreasingOrder = (PlayerRegistration, PlayerRegistration) -> Bool |
||||
|
||||
func players() -> [PlayerRegistration] { |
||||
|
||||
self.tournamentStore.playerRegistrations.filter { $0.teamRegistration == self.id }.sorted { (lhs, rhs) in |
||||
let predicates: [AreInIncreasingOrder] = [ |
||||
{ $0.sex?.rawValue ?? 0 < $1.sex?.rawValue ?? 0 }, |
||||
{ $0.rank ?? 0 < $1.rank ?? 0 }, |
||||
{ $0.lastName < $1.lastName}, |
||||
{ $0.firstName < $1.firstName } |
||||
] |
||||
|
||||
for predicate in predicates { |
||||
if !predicate(lhs, rhs) && !predicate(rhs, lhs) { |
||||
continue |
||||
} |
||||
|
||||
return predicate(lhs, rhs) |
||||
} |
||||
|
||||
return false |
||||
} |
||||
} |
||||
|
||||
func setWeight(from players: [PlayerRegistration], inTournamentCategory tournamentCategory: TournamentCategory) { |
||||
let significantPlayerCount = significantPlayerCount() |
||||
weight = (players.prefix(significantPlayerCount).map { $0.computedRank } + missingPlayerType(inTournamentCategory: tournamentCategory).map { unrankValue(for: $0 == 1 ? true : false ) }).prefix(significantPlayerCount).reduce(0,+) |
||||
} |
||||
|
||||
func significantPlayerCount() -> Int { |
||||
return tournamentObject()?.significantPlayerCount() ?? 2 |
||||
} |
||||
|
||||
func missingPlayerType(inTournamentCategory tournamentCategory: TournamentCategory) -> [Int] { |
||||
let players = unsortedPlayers() |
||||
if players.count >= 2 { return [] } |
||||
let s = players.compactMap { $0.sex?.rawValue } |
||||
var missing = tournamentCategory.mandatoryPlayerType() |
||||
s.forEach { i in |
||||
if let index = missing.firstIndex(of: i) { |
||||
missing.remove(at: index) |
||||
} |
||||
} |
||||
return missing |
||||
} |
||||
|
||||
func unrankValue(for malePlayer: Bool) -> Int { |
||||
return tournamentObject()?.unrankValue(for: malePlayer) ?? 70_000 |
||||
} |
||||
|
||||
func groupStageObject() -> GroupStage? { |
||||
guard let groupStage else { return nil } |
||||
return self.tournamentStore.groupStages.findById(groupStage) |
||||
} |
||||
|
||||
func initialRound() -> Round? { |
||||
guard let bracketPosition else { return nil } |
||||
let roundIndex = RoundRule.roundIndex(fromMatchIndex: bracketPosition / 2) |
||||
return self.tournamentStore.rounds.first(where: { $0.index == roundIndex }) |
||||
} |
||||
|
||||
func initialMatch() -> Match? { |
||||
guard let bracketPosition else { return nil } |
||||
guard let initialRoundObject = initialRound() else { return nil } |
||||
return self.tournamentStore.matches.first(where: { $0.round == initialRoundObject.id && $0.index == bracketPosition / 2 }) |
||||
} |
||||
|
||||
|
||||
func tournamentObject() -> Tournament? { |
||||
return Store.main.findById(tournament) |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _tournament = "tournament" |
||||
case _groupStage = "groupStage" |
||||
case _registrationDate = "registrationDate" |
||||
case _callDate = "callDate" |
||||
case _bracketPosition = "bracketPosition" |
||||
case _groupStagePosition = "groupStagePosition" |
||||
case _comment = "comment" |
||||
case _source = "source" |
||||
case _sourceValue = "sourceValue" |
||||
case _logo = "logo" |
||||
case _name = "name" |
||||
case _wildCardBracket = "wildCardBracket" |
||||
case _wildCardGroupStage = "wildCardGroupStage" |
||||
case _weight = "weight" |
||||
case _walkOut = "walkOut" |
||||
case _lockedWeight = "lockedWeight" |
||||
case _confirmationDate = "confirmationDate" |
||||
case _qualified = "qualified" |
||||
case _finalRanking = "finalRanking" |
||||
case _pointsEarned = "pointsEarned" |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
try container.encode(tournament, forKey: ._tournament) |
||||
|
||||
if let groupStage = groupStage { |
||||
try container.encode(groupStage, forKey: ._groupStage) |
||||
} else { |
||||
try container.encodeNil(forKey: ._groupStage) |
||||
} |
||||
|
||||
if let registrationDate = registrationDate { |
||||
try container.encode(registrationDate, forKey: ._registrationDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._registrationDate) |
||||
} |
||||
|
||||
if let callDate = callDate { |
||||
try container.encode(callDate, forKey: ._callDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._callDate) |
||||
} |
||||
|
||||
if let bracketPosition = bracketPosition { |
||||
try container.encode(bracketPosition, forKey: ._bracketPosition) |
||||
} else { |
||||
try container.encodeNil(forKey: ._bracketPosition) |
||||
} |
||||
|
||||
if let groupStagePosition = groupStagePosition { |
||||
try container.encode(groupStagePosition, forKey: ._groupStagePosition) |
||||
} else { |
||||
try container.encodeNil(forKey: ._groupStagePosition) |
||||
} |
||||
|
||||
if let comment = comment { |
||||
try container.encode(comment, forKey: ._comment) |
||||
} else { |
||||
try container.encodeNil(forKey: ._comment) |
||||
} |
||||
|
||||
if let source = source { |
||||
try container.encode(source, forKey: ._source) |
||||
} else { |
||||
try container.encodeNil(forKey: ._source) |
||||
} |
||||
|
||||
if let sourceValue = sourceValue { |
||||
try container.encode(sourceValue, forKey: ._sourceValue) |
||||
} else { |
||||
try container.encodeNil(forKey: ._sourceValue) |
||||
} |
||||
|
||||
if let logo = logo { |
||||
try container.encode(logo, forKey: ._logo) |
||||
} else { |
||||
try container.encodeNil(forKey: ._logo) |
||||
} |
||||
|
||||
if let name = name { |
||||
try container.encode(name, forKey: ._name) |
||||
} else { |
||||
try container.encodeNil(forKey: ._name) |
||||
} |
||||
|
||||
try container.encode(walkOut, forKey: ._walkOut) |
||||
try container.encode(wildCardBracket, forKey: ._wildCardBracket) |
||||
try container.encode(wildCardGroupStage, forKey: ._wildCardGroupStage) |
||||
try container.encode(weight, forKey: ._weight) |
||||
|
||||
if let lockedWeight = lockedWeight { |
||||
try container.encode(lockedWeight, forKey: ._lockedWeight) |
||||
} else { |
||||
try container.encodeNil(forKey: ._lockedWeight) |
||||
} |
||||
|
||||
if let confirmationDate = confirmationDate { |
||||
try container.encode(confirmationDate, forKey: ._confirmationDate) |
||||
} else { |
||||
try container.encodeNil(forKey: ._confirmationDate) |
||||
} |
||||
|
||||
try container.encode(qualified, forKey: ._qualified) |
||||
|
||||
if let finalRanking { |
||||
try container.encode(finalRanking, forKey: ._finalRanking) |
||||
} else { |
||||
try container.encodeNil(forKey: ._finalRanking) |
||||
} |
||||
|
||||
if let pointsEarned { |
||||
try container.encode(pointsEarned, forKey: ._pointsEarned) |
||||
} else { |
||||
try container.encodeNil(forKey: ._pointsEarned) |
||||
} |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.teamRegistrations.writeChangeAndInsertOnServer(instance: self) |
||||
for playerRegistration in self.unsortedPlayers() { |
||||
playerRegistration.insertOnServer() |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
extension TeamRegistration: Hashable { |
||||
static func == (lhs: TeamRegistration, rhs: TeamRegistration) -> Bool { |
||||
lhs.id == rhs.id |
||||
} |
||||
|
||||
func hash(into hasher: inout Hasher) { |
||||
hasher.combine(id) |
||||
} |
||||
} |
||||
|
||||
enum TeamDataSource: Int, Codable { |
||||
case beachPadel |
||||
} |
||||
@ -1,120 +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) |
||||
|
||||
if let teamRegistration = teamRegistration { |
||||
try container.encode(teamRegistration, forKey: ._teamRegistration) |
||||
} else { |
||||
try container.encodeNil(forKey: ._teamRegistration) |
||||
} |
||||
|
||||
//try container.encode(playerRegistrations, forKey: ._playerRegistrations) |
||||
|
||||
if let score = score { |
||||
try container.encode(score, forKey: ._score) |
||||
} else { |
||||
try container.encodeNil(forKey: ._score) |
||||
} |
||||
|
||||
if let walkOut = walkOut { |
||||
try container.encode(walkOut, forKey: ._walkOut) |
||||
} else { |
||||
try container.encodeNil(forKey: ._walkOut) |
||||
} |
||||
|
||||
if let luckyLoser = luckyLoser { |
||||
try container.encode(luckyLoser, forKey: ._luckyLoser) |
||||
} else { |
||||
try container.encodeNil(forKey: ._luckyLoser) |
||||
} |
||||
} |
||||
|
||||
func insertOnServer() { |
||||
self.tournamentStore.teamScores.writeChangeAndInsertOnServer(instance: self) |
||||
} |
||||
|
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -1,59 +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() |
||||
|
||||
convenience init(tournament: Tournament) { |
||||
self.init(identifier: tournament.id, parameter: "tournament") |
||||
} |
||||
|
||||
required init(identifier: String, parameter: String) { |
||||
|
||||
super.init(identifier: identifier, parameter: parameter) |
||||
|
||||
var synchronized: Bool = true |
||||
let indexed: Bool = true |
||||
|
||||
#if _DEBUG_OPTIONS |
||||
if let sync = PListReader.readBool(plist: "local", key: "synchronized") { |
||||
synchronized = sync |
||||
} |
||||
#endif |
||||
|
||||
self.groupStages = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.rounds = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.teamRegistrations = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.playerRegistrations = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.matches = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.teamScores = self.registerCollection(synchronized: synchronized, indexed: indexed) |
||||
self.matchSchedulers = self.registerCollection(synchronized: false, indexed: indexed) |
||||
|
||||
self.loadCollectionsFromServerIfNoFile() |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -1,266 +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 deviceId: String? |
||||
|
||||
init(username: String, email: String, firstName: String, lastName: String, phone: String?, country: String?) { |
||||
self.username = username |
||||
self.firstName = firstName |
||||
self.lastName = lastName |
||||
self.email = email |
||||
self.phone = phone |
||||
self.country = country |
||||
} |
||||
|
||||
public func uuid() throws -> UUID { |
||||
if let uuid = UUID(uuidString: self.id) { |
||||
return uuid |
||||
} |
||||
throw UUIDError.cantConvertString(string: self.id) |
||||
} |
||||
|
||||
func currentPlayerData() -> ImportedPlayer? { |
||||
guard let licenceId else { return nil } |
||||
let federalContext = PersistenceController.shared.localContainer.viewContext |
||||
let fetchRequest = ImportedPlayer.fetchRequest() |
||||
let predicate = NSPredicate(format: "license == %@", licenceId) |
||||
fetchRequest.predicate = predicate |
||||
return try? federalContext.fetch(fetchRequest).first |
||||
} |
||||
|
||||
func defaultSignature() -> String { |
||||
return "Sportivement,\n\(firstName) \(lastName), votre JAP." |
||||
} |
||||
|
||||
func hasTenupClubs() -> Bool { |
||||
self.clubsObjects().filter({ $0.code != nil }).isEmpty == false |
||||
} |
||||
|
||||
func hasFavoriteClubsAndCreatedClubs() -> Bool { |
||||
clubsObjects(includeCreated: true).isEmpty == false |
||||
} |
||||
|
||||
func setUserClub(_ userClub: Club) { |
||||
self.clubs.insert(userClub.id, at: 0) |
||||
} |
||||
|
||||
func clubsObjects(includeCreated: Bool = false) -> [Club] { |
||||
return DataStore.shared.clubs.filter({ (includeCreated && $0.creator == id) || clubs.contains($0.id) }) |
||||
} |
||||
|
||||
func createdClubsObjectsNotFavorite() -> [Club] { |
||||
return DataStore.shared.clubs.filter({ ($0.creator == id) && clubs.contains($0.id) == false }) |
||||
} |
||||
|
||||
func saveMatchFormatsDefaultDuration(_ matchFormat: MatchFormat, estimatedDuration: Int) { |
||||
if estimatedDuration == matchFormat.defaultEstimatedDuration { |
||||
matchFormatsDefaultDuration?.removeValue(forKey: matchFormat) |
||||
} else { |
||||
matchFormatsDefaultDuration = matchFormatsDefaultDuration ?? [MatchFormat: Int]() |
||||
matchFormatsDefaultDuration?[matchFormat] = estimatedDuration |
||||
} |
||||
} |
||||
|
||||
func addClub(_ club: Club) { |
||||
if !self.clubs.contains(where: { $0.id == club.id }) { |
||||
self.clubs.append(club.id) |
||||
} |
||||
} |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case _id = "id" |
||||
case _username = "username" |
||||
case _email = "email" |
||||
case _clubs = "clubs" |
||||
case _umpireCode = "umpireCode" |
||||
case _licenceId = "licenceId" |
||||
case _firstName = "firstName" |
||||
case _lastName = "lastName" |
||||
case _phone = "phone" |
||||
case _country = "country" |
||||
case _summonsMessageBody = "summonsMessageBody" |
||||
case _summonsMessageSignature = "summonsMessageSignature" |
||||
case _summonsAvailablePaymentMethods = "summonsAvailablePaymentMethods" |
||||
case _summonsDisplayFormat = "summonsDisplayFormat" |
||||
case _summonsDisplayEntryFee = "summonsDisplayEntryFee" |
||||
case _summonsUseFullCustomMessage = "summonsUseFullCustomMessage" |
||||
case _matchFormatsDefaultDuration = "matchFormatsDefaultDuration" |
||||
case _bracketMatchFormatPreference = "bracketMatchFormatPreference" |
||||
case _groupStageMatchFormatPreference = "groupStageMatchFormatPreference" |
||||
case _loserBracketMatchFormatPreference = "loserBracketMatchFormatPreference" |
||||
case _deviceId = "deviceId" |
||||
} |
||||
|
||||
func encode(to encoder: Encoder) throws { |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
|
||||
try container.encode(id, forKey: ._id) |
||||
try container.encode(username, forKey: ._username) |
||||
try container.encode(email, forKey: ._email) |
||||
try container.encode(clubs, forKey: ._clubs) |
||||
|
||||
if let umpireCode = umpireCode { |
||||
try container.encode(umpireCode, forKey: ._umpireCode) |
||||
} else { |
||||
try container.encodeNil(forKey: ._umpireCode) |
||||
} |
||||
|
||||
if let licenceId = licenceId { |
||||
try container.encode(licenceId, forKey: ._licenceId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._licenceId) |
||||
} |
||||
|
||||
try container.encode(firstName, forKey: ._firstName) |
||||
try container.encode(lastName, forKey: ._lastName) |
||||
|
||||
if let phone = phone { |
||||
try container.encode(phone, forKey: ._phone) |
||||
} else { |
||||
try container.encodeNil(forKey: ._phone) |
||||
} |
||||
|
||||
if let country = country { |
||||
try container.encode(country, forKey: ._country) |
||||
} else { |
||||
try container.encodeNil(forKey: ._country) |
||||
} |
||||
|
||||
if let summonsMessageBody = summonsMessageBody { |
||||
try container.encode(summonsMessageBody, forKey: ._summonsMessageBody) |
||||
} else { |
||||
try container.encodeNil(forKey: ._summonsMessageBody) |
||||
} |
||||
|
||||
if let summonsMessageSignature = summonsMessageSignature { |
||||
try container.encode(summonsMessageSignature, forKey: ._summonsMessageSignature) |
||||
} else { |
||||
try container.encodeNil(forKey: ._summonsMessageSignature) |
||||
} |
||||
|
||||
if let summonsAvailablePaymentMethods = summonsAvailablePaymentMethods { |
||||
try container.encode(summonsAvailablePaymentMethods, forKey: ._summonsAvailablePaymentMethods) |
||||
} else { |
||||
try container.encodeNil(forKey: ._summonsAvailablePaymentMethods) |
||||
} |
||||
|
||||
try container.encode(summonsDisplayFormat, forKey: ._summonsDisplayFormat) |
||||
try container.encode(summonsDisplayEntryFee, forKey: ._summonsDisplayEntryFee) |
||||
try container.encode(summonsUseFullCustomMessage, forKey: ._summonsUseFullCustomMessage) |
||||
|
||||
if let matchFormatsDefaultDuration = matchFormatsDefaultDuration { |
||||
try container.encode(matchFormatsDefaultDuration, forKey: ._matchFormatsDefaultDuration) |
||||
} else { |
||||
try container.encodeNil(forKey: ._matchFormatsDefaultDuration) |
||||
} |
||||
|
||||
if let bracketMatchFormatPreference = bracketMatchFormatPreference { |
||||
try container.encode(bracketMatchFormatPreference, forKey: ._bracketMatchFormatPreference) |
||||
} else { |
||||
try container.encodeNil(forKey: ._bracketMatchFormatPreference) |
||||
} |
||||
|
||||
if let groupStageMatchFormatPreference = groupStageMatchFormatPreference { |
||||
try container.encode(groupStageMatchFormatPreference, forKey: ._groupStageMatchFormatPreference) |
||||
} else { |
||||
try container.encodeNil(forKey: ._groupStageMatchFormatPreference) |
||||
} |
||||
|
||||
if let loserBracketMatchFormatPreference = loserBracketMatchFormatPreference { |
||||
try container.encode(loserBracketMatchFormatPreference, forKey: ._loserBracketMatchFormatPreference) |
||||
} else { |
||||
try container.encodeNil(forKey: ._loserBracketMatchFormatPreference) |
||||
} |
||||
|
||||
if let deviceId { |
||||
try container.encode(deviceId, forKey: ._deviceId) |
||||
} else { |
||||
try container.encodeNil(forKey: ._deviceId) |
||||
} |
||||
|
||||
} |
||||
|
||||
static func placeHolder() -> User { |
||||
return User(username: "", email: "", firstName: "", lastName: "", phone: nil, country: nil) |
||||
} |
||||
|
||||
} |
||||
|
||||
class UserCreationForm: User, UserPasswordBase { |
||||
|
||||
init(user: User, username: String, password: String, firstName: String, lastName: String, email: String, phone: String?, country: String?) { |
||||
self.password = password |
||||
super.init(username: username, email: email, firstName: firstName, lastName: lastName, phone: phone, country: country) |
||||
|
||||
self.summonsMessageBody = user.summonsMessageBody |
||||
self.summonsMessageSignature = user.summonsMessageSignature |
||||
self.summonsAvailablePaymentMethods = user.summonsAvailablePaymentMethods |
||||
self.summonsDisplayFormat = user.summonsDisplayFormat |
||||
self.summonsDisplayEntryFee = user.summonsDisplayEntryFee |
||||
self.summonsUseFullCustomMessage = user.summonsUseFullCustomMessage |
||||
self.matchFormatsDefaultDuration = user.matchFormatsDefaultDuration |
||||
self.bracketMatchFormatPreference = user.bracketMatchFormatPreference |
||||
self.groupStageMatchFormatPreference = user.groupStageMatchFormatPreference |
||||
self.loserBracketMatchFormatPreference = user.loserBracketMatchFormatPreference |
||||
|
||||
} |
||||
|
||||
required init(from decoder: Decoder) throws { |
||||
fatalError("init(from:) has not been implemented") |
||||
} |
||||
|
||||
public var password: String |
||||
|
||||
private enum CodingKeys: String, CodingKey { |
||||
case password |
||||
} |
||||
|
||||
override func encode(to encoder: Encoder) throws { |
||||
try super.encode(to: encoder) |
||||
var container = encoder.container(keyedBy: CodingKeys.self) |
||||
try container.encode(self.password, forKey: .password) |
||||
} |
||||
} |
||||
@ -1,51 +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) } |
||||
} |
||||
} |
||||
|
||||
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)" |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -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,26 +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 |
||||
} |
||||
|
||||
} |
||||
@ -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,234 +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 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)) |
||||
} |
||||
} |
||||
@ -1,25 +0,0 @@ |
||||
// |
||||
// FixedWidthInteger+Extensions.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 03/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
public extension FixedWidthInteger { |
||||
func ordinalFormattedSuffix() -> String { |
||||
switch self { |
||||
case 1: return "er" |
||||
default: return "ème" |
||||
} |
||||
} |
||||
|
||||
func ordinalFormatted() -> String { |
||||
return self.formatted() + self.ordinalFormattedSuffix() |
||||
} |
||||
|
||||
var pluralSuffix: String { |
||||
return self > 1 ? "s" : "" |
||||
} |
||||
} |
||||
@ -1,23 +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() |
||||
} |
||||
} |
||||
@ -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,203 +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 { |
||||
return (self.count > length) ? self.prefix(length) + trailing : self |
||||
} |
||||
|
||||
var trimmed: String { |
||||
trimmingCharacters(in: .whitespacesAndNewlines) |
||||
} |
||||
|
||||
func replaceCharactersFromSet(characterSet: CharacterSet, replacementString: String = "") -> String { |
||||
components(separatedBy: characterSet).joined(separator:replacementString) |
||||
} |
||||
|
||||
var canonicalVersion: String { |
||||
trimmed.replaceCharactersFromSet(characterSet: .punctuationCharacters, replacementString: " ").folding(options: .diacriticInsensitive, locale: .current).lowercased() |
||||
} |
||||
|
||||
var canonicalVersionWithPunctuation: String { |
||||
trimmed.folding(options: .diacriticInsensitive, locale: .current).lowercased() |
||||
} |
||||
|
||||
var removingFirstCharacter: String { |
||||
String(dropFirst()) |
||||
} |
||||
|
||||
func isValidEmail() -> Bool { |
||||
let emailRegEx = "^[A-Z0-9a-z._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,64}$" |
||||
let emailPredicate = NSPredicate(format:"SELF MATCHES %@", emailRegEx) |
||||
return emailPredicate.evaluate(with: self) |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Club Name |
||||
extension String { |
||||
func acronym() -> String { |
||||
let acronym = canonicalVersion.replaceCharactersFromSet(characterSet: .whitespacesAndNewlines) |
||||
if acronym.count > 10 { |
||||
return concatenateFirstLetters().uppercased() |
||||
} else { |
||||
return acronym.uppercased() |
||||
} |
||||
} |
||||
|
||||
func concatenateFirstLetters() -> String { |
||||
// Split the input into sentences |
||||
let sentences = self.components(separatedBy: .whitespacesAndNewlines) |
||||
if sentences.count == 1 { |
||||
return String(self.prefix(10)) |
||||
} |
||||
// Extract the first character of each sentence |
||||
let firstLetters = sentences.compactMap { sentence -> Character? in |
||||
let trimmedSentence = sentence.trimmingCharacters(in: .whitespacesAndNewlines) |
||||
if trimmedSentence.count > 2 { |
||||
if let firstCharacter = trimmedSentence.first { |
||||
return firstCharacter |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
// Join the first letters together into a string |
||||
let result = String(firstLetters) |
||||
return result |
||||
} |
||||
} |
||||
|
||||
// MARK: - FFT License |
||||
extension String { |
||||
var computedLicense: String { |
||||
if let licenseKey { |
||||
return self + licenseKey |
||||
} else { |
||||
return self |
||||
} |
||||
} |
||||
|
||||
var strippedLicense: String? { |
||||
var dropFirst = 0 |
||||
if hasPrefix("0") { |
||||
dropFirst = 1 |
||||
} |
||||
if let match = self.dropFirst(dropFirst).firstMatch(of: /[0-9]{6,8}/) { |
||||
let lic = String(self.dropFirst(dropFirst)[match.range.lowerBound..<match.range.upperBound]) |
||||
return lic |
||||
} else { |
||||
return nil |
||||
} |
||||
} |
||||
|
||||
var isLicenseNumber: Bool { |
||||
if let match = self.firstMatch(of: /[0-9]{6,8}[A-Z]/) { |
||||
let lic = String(self[match.range.lowerBound..<match.range.upperBound].dropLast(1)) |
||||
let lastLetter = String(self[match.range.lowerBound..<match.range.upperBound].suffix(1)) |
||||
|
||||
if let lkey = lic.licenseKey { |
||||
return lkey == lastLetter |
||||
} |
||||
} |
||||
|
||||
return false |
||||
} |
||||
|
||||
var licenseKey: String? { |
||||
if let intValue = Int(self) { |
||||
var value = intValue |
||||
value -= 1 |
||||
value = value % 23 |
||||
let v = UnicodeScalar("A").value |
||||
let i = Int(v) |
||||
if let s = UnicodeScalar(i + value) { |
||||
var c = Character(s) |
||||
if c >= "I" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
if c >= "O" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
|
||||
if c >= "Q" { |
||||
value += 1 |
||||
if let newS = UnicodeScalar(i + value) { |
||||
c = Character(newS) |
||||
} |
||||
} |
||||
|
||||
return String(c) |
||||
} |
||||
} |
||||
return nil |
||||
} |
||||
|
||||
func licencesFound() -> [String] { |
||||
let matches = self.matches(of: /[1-9][0-9]{5,7}/) |
||||
return matches.map { String(self[$0.range]) } |
||||
} |
||||
} |
||||
|
||||
// MARK: - FFT Source Importing |
||||
extension String { |
||||
enum RegexStatic { |
||||
static let mobileNumber = /^0[6-7]/ |
||||
//static let mobileNumber = /^(?:(?:\+|00)33[\s.-]{0,3}(?:\(0\)[\s.-]{0,3})?|0)[1-9](?:(?:[\s.-]?\d{2}){4}|\d{2}(?:[\s.-]?\d{3}){2})$/ |
||||
} |
||||
|
||||
func isMobileNumber() -> Bool { |
||||
firstMatch(of: RegexStatic.mobileNumber) != nil |
||||
} |
||||
|
||||
//april 04-2024 bug with accent characters / adobe / fft |
||||
mutating func replace(characters: [(Character, Character)]) { |
||||
for (targetChar, replacementChar) in characters { |
||||
self = String(self.map { $0 == targetChar ? replacementChar : $0 }) |
||||
} |
||||
} |
||||
} |
||||
|
||||
// MARK: - Player Names |
||||
extension StringProtocol { |
||||
var firstUppercased: String { prefix(1).uppercased() + dropFirst() } |
||||
var firstCapitalized: String { prefix(1).capitalized + dropFirst() } |
||||
} |
||||
|
||||
// MARK: - todo clean up ?? |
||||
extension LosslessStringConvertible { |
||||
var string: String { .init(self) } |
||||
} |
||||
|
||||
extension String { |
||||
func createFile(_ withName: String = "temp", _ exportedFormat: ExportFormat = .rawText) -> URL { |
||||
let url = FileManager.default.temporaryDirectory |
||||
.appendingPathComponent(withName) |
||||
.appendingPathExtension(exportedFormat.suffix) |
||||
let string = self |
||||
try? FileManager.default.removeItem(at: url) |
||||
try? string.write(to: url, atomically: true, encoding: .utf8) |
||||
return url |
||||
} |
||||
} |
||||
|
||||
extension String { |
||||
func toInt() -> Int? { |
||||
Int(self) |
||||
} |
||||
} |
||||
@ -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,164 +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 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"> {{roundLabel}}</li> |
||||
<li class="spacer" style="transform: translateY(-20px);"> |
||||
{{roundLabel}} |
||||
<div>{{formatLabel}}</div> |
||||
</li> |
||||
{{match-template}} |
||||
</ul> |
||||
|
||||
@ -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"> </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> |
||||
|
||||
@ -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") |
||||
} |
||||
} |
||||
} |
||||
} |
||||
Binary file not shown.
@ -1,224 +0,0 @@ |
||||
// |
||||
// CloudConvert.swift |
||||
// Padel Tournament |
||||
// |
||||
// Created by Razmig Sarkissian on 14/09/2023. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class CloudConvert { |
||||
|
||||
enum CloudConvertionError: LocalizedError { |
||||
case unknownError |
||||
case serviceError(ErrorResponse) |
||||
case urlNotFound(String) |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .unknownError: |
||||
return "Erreur" |
||||
case .serviceError(let errorResponse): |
||||
return errorResponse.error |
||||
case .urlNotFound(let url): |
||||
return "L'URL [\(url)] n'est pas valide" |
||||
} |
||||
} |
||||
} |
||||
|
||||
static let manager = CloudConvert() |
||||
|
||||
func uploadFile(_ url: URL) async throws -> String { |
||||
let taskResponse = try await createJob(url) |
||||
let uploadResponse = try await uploadFile(taskResponse, url: url) |
||||
var fileReady = false |
||||
while fileReady == false { |
||||
try await Task.sleep(nanoseconds: 3_000_000_000) |
||||
let progressResponse = try await checkFile(id: uploadResponse.data.id) |
||||
if progressResponse.data.step == "finish" && progressResponse.data.stepPercent == 100 { |
||||
fileReady = true |
||||
print("progressResponse.data.minutes", progressResponse.data.minutes) |
||||
} |
||||
} |
||||
|
||||
let convertedFile = try await downloadConvertedFile(id: uploadResponse.data.id) |
||||
return convertedFile |
||||
} |
||||
|
||||
func createJob(_ url: URL) async throws -> TaskResponse { |
||||
guard let taskURL = URL(string: "https://api.convertio.co/convert") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert") |
||||
} |
||||
var request: URLRequest = URLRequest(url: taskURL) |
||||
let parameters = """ |
||||
{"apikey":"d97cf13ef6d163e5e386c381fc8d256f","input":"upload","file":"","filename":"","outputformat":"csv","options":""} |
||||
""" |
||||
|
||||
|
||||
let postData = parameters.data(using: .utf8) |
||||
request.httpMethod = "POST" |
||||
request.httpBody = postData |
||||
|
||||
let task = try await URLSession.shared.data(for: request) |
||||
//print("tried: \(request.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(TaskResponse.self, from: task.0) |
||||
} |
||||
|
||||
func uploadFile(_ response: TaskResponse, url: URL) async throws -> UploadResponse { |
||||
guard let uploadTaskURL = URL(string: "https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(response.data.id)/\(url.encodedLastPathComponent)") |
||||
} |
||||
var uploadRequest: URLRequest = URLRequest(url: uploadTaskURL) |
||||
uploadRequest.httpMethod = "PUT" |
||||
let uploadTask = try await URLSession.shared.upload(for: uploadRequest, fromFile: url) |
||||
|
||||
//print("tried: \(uploadRequest.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: uploadTask.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(UploadResponse.self, from: uploadTask.0) |
||||
} |
||||
|
||||
func checkFile(id: String) async throws -> ProgressResponse { |
||||
guard let taskURL = URL(string: "https://api.convertio.co/convert/\(id)/status") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/status") |
||||
} |
||||
var request: URLRequest = URLRequest(url: taskURL) |
||||
request.httpMethod = "GET" |
||||
let task = try await URLSession.shared.data(for: request) |
||||
|
||||
//print("tried: \(request.url)") |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: task.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
return try JSONDecoder().decode(ProgressResponse.self, from: task.0) |
||||
} |
||||
|
||||
func downloadConvertedFile(id: String) async throws -> String { |
||||
// try await Task.sleep(nanoseconds: 3_000_000_000) |
||||
|
||||
guard let downloadTaskURL = URL(string: "https://api.convertio.co/convert/\(id)/dl/base64") else { |
||||
throw CloudConvertionError.urlNotFound("https://api.convertio.co/convert/\(id)/dl/base64") |
||||
} |
||||
var downloadRequest: URLRequest = URLRequest(url: downloadTaskURL) |
||||
downloadRequest.httpMethod = "GET" |
||||
let downloadTask = try await URLSession.shared.data(for: downloadRequest) |
||||
if let errorResponse = try? JSONDecoder().decode(ErrorResponse.self, from: downloadTask.0) { |
||||
print("errorResponse.error", errorResponse.error) |
||||
throw CloudConvertionError.serviceError(errorResponse) |
||||
} |
||||
|
||||
//print("tried: \(downloadRequest.url)") |
||||
let dataResponse = try JSONDecoder().decode(DataResponse.self, from: downloadTask.0) |
||||
if let decodedData = Data(base64Encoded: dataResponse.data.content), let string = String(data: decodedData, encoding: .utf8) { |
||||
return string |
||||
} |
||||
|
||||
throw CloudConvertionError.unknownError |
||||
} |
||||
} |
||||
|
||||
// MARK: - DataResponse |
||||
struct DataResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: DataDownloadClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct DataDownloadClass: Decodable { |
||||
let id, encode, content: String |
||||
} |
||||
|
||||
|
||||
// MARK: - ErrorResponse |
||||
struct ErrorResponse: Decodable { |
||||
let code: Int |
||||
let status, error: String |
||||
} |
||||
|
||||
|
||||
// MARK: - TaskResponse |
||||
struct TaskResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: DataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct DataClass: Decodable { |
||||
let id: String |
||||
} |
||||
|
||||
// MARK: - ProgressResponse |
||||
struct ProgressResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: ProgressDataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct ProgressDataClass: Decodable { |
||||
let id, step: String |
||||
let stepPercent: Int |
||||
let minutes: String |
||||
|
||||
enum CodingKeys: String, CodingKey { |
||||
case id, step |
||||
case stepPercent = "step_percent" |
||||
case minutes |
||||
} |
||||
|
||||
init(from decoder: Decoder) throws { |
||||
let container = try decoder.container(keyedBy: CodingKeys.self) |
||||
id = try container.decode(String.self, forKey: .id) |
||||
step = try container.decode(String.self, forKey: .step) |
||||
minutes = try container.decode(String.self, forKey: .minutes) |
||||
if let value = try? container.decode(String.self, forKey: .stepPercent) { |
||||
print(value) |
||||
stepPercent = Int(value) ?? 0 |
||||
} else { |
||||
stepPercent = try container.decode(Int.self, forKey: .stepPercent) |
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
// MARK: - Output |
||||
struct Output: Decodable { |
||||
let url: String |
||||
let size: String |
||||
} |
||||
|
||||
// MARK: - UploadResponse |
||||
struct UploadResponse: Decodable { |
||||
let code: Int |
||||
let status: String |
||||
let data: UploadDataClass |
||||
} |
||||
|
||||
// MARK: - DataClass |
||||
struct UploadDataClass: Decodable { |
||||
let id, file: String |
||||
let size: Int |
||||
} |
||||
|
||||
extension URL { |
||||
var encodedLastPathComponent: String { |
||||
if #available(iOS 17.0, *) { |
||||
lastPathComponent |
||||
} else { |
||||
lastPathComponent.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? lastPathComponent |
||||
} |
||||
|
||||
} |
||||
} |
||||
@ -1,205 +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 |
||||
} |
||||
|
||||
enum ContactType: Identifiable { |
||||
case mail(date: Date?, recipients: [String]?, bccRecipients: [String]?, body: String?, subject: String?, tournamentBuild: TournamentBuild?) |
||||
case message(date: Date?, recipients: [String]?, body: String?, tournamentBuild: TournamentBuild?) |
||||
|
||||
var id: Int { |
||||
switch self { |
||||
case .message: return 0 |
||||
case .mail: return 1 |
||||
} |
||||
} |
||||
} |
||||
|
||||
extension ContactType { |
||||
static let defaultCustomMessage: String = "Il est conseillé de vous présenter 10 minutes avant de jouer.\nMerci de me confirmer votre présence avec votre nom et de prévenir votre partenaire." |
||||
static let defaultAvailablePaymentMethods: String = "Règlement possible par chèque ou espèces." |
||||
|
||||
static func callingCustomMessage(source: String? = nil, tournament: Tournament?, startDate: Date?, roundLabel: String) -> String { |
||||
let tournamentCustomMessage = source ?? DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage |
||||
let clubName = tournament?.clubName ?? "" |
||||
|
||||
var text = tournamentCustomMessage |
||||
let date = startDate ?? tournament?.startDate ?? Date() |
||||
|
||||
if let tournament { |
||||
text = text.replacingOccurrences(of: "#titre", with: tournament.tournamentTitle(.short)) |
||||
text = text.replacingOccurrences(of: "#prix", with: tournament.entryFeeMessage) |
||||
} |
||||
|
||||
text = text.replacingOccurrences(of: "#club", with: clubName) |
||||
text = text.replacingOccurrences(of: "#manche", with: roundLabel.lowercased()) |
||||
text = text.replacingOccurrences(of: "#jour", with: "\(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide)))") |
||||
text = text.replacingOccurrences(of: "#horaire", with: "\(date.formatted(Date.FormatStyle().hour().minute()))") |
||||
|
||||
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature() |
||||
|
||||
text = text.replacingOccurrences(of: "#signature", with: signature) |
||||
return text |
||||
} |
||||
|
||||
static func callingMessage(tournament: Tournament?, startDate: Date?, roundLabel: String, matchFormat: MatchFormat?, reSummon: Bool = false) -> String { |
||||
|
||||
let useFullCustomMessage = DataStore.shared.user.summonsUseFullCustomMessage |
||||
|
||||
if useFullCustomMessage { |
||||
return callingCustomMessage(tournament: tournament, startDate: startDate, roundLabel: roundLabel) |
||||
} |
||||
|
||||
let date = startDate ?? tournament?.startDate ?? Date() |
||||
|
||||
let clubName = tournament?.clubName ?? "" |
||||
let message = DataStore.shared.user.summonsMessageBody ?? defaultCustomMessage |
||||
let signature = DataStore.shared.user.summonsMessageSignature ?? DataStore.shared.user.defaultSignature() |
||||
|
||||
let localizedCalled = "convoqué" + (tournament?.tournamentCategory == .women ? "e" : "") + "s" |
||||
|
||||
var entryFeeMessage: String? { |
||||
(DataStore.shared.user.summonsDisplayEntryFee) ? tournament?.entryFeeMessage : nil |
||||
} |
||||
|
||||
var computedMessage: String { |
||||
[entryFeeMessage, message].compacted().map { $0.trimmed }.joined(separator: "\n\n") |
||||
} |
||||
|
||||
let intro = reSummon ? "Suite à des forfaits, vous êtes finalement" : "Vous êtes" |
||||
|
||||
if let tournament { |
||||
return "Bonjour,\n\n\(intro) \(localizedCalled) pour jouer en \(roundLabel.lowercased()) du \(tournament.tournamentTitle(.short)) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\n" + computedMessage + "\n\n\(signature)" |
||||
} else { |
||||
return "Bonjour,\n\n\(intro) \(localizedCalled) \(roundLabel) au \(clubName) le \(date.formatted(Date.FormatStyle().weekday(.wide).day().month(.wide))) à \(date.formatted(Date.FormatStyle().hour().minute())).\n\nMerci de confirmer en répondant à ce message et de prévenir votre partenaire !\n\n\(signature)" |
||||
} |
||||
} |
||||
} |
||||
|
||||
|
||||
struct MessageComposeView: UIViewControllerRepresentable { |
||||
typealias Completion = (_ result: MessageComposeResult) -> Void |
||||
|
||||
static var canSendText: Bool { MFMessageComposeViewController.canSendText() } |
||||
|
||||
let recipients: [String]? |
||||
let body: String? |
||||
let completion: Completion? |
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { |
||||
guard Self.canSendText else { |
||||
let errorView = ContentUnavailableView("Aucun compte de messagerie", systemImage: "xmark", description: Text("Aucun compte de messagerie n'est configuré sur cet appareil.")) |
||||
return UIHostingController(rootView: errorView) |
||||
} |
||||
|
||||
let controller = MFMessageComposeViewController() |
||||
controller.messageComposeDelegate = context.coordinator |
||||
controller.recipients = recipients |
||||
controller.body = body |
||||
|
||||
return controller |
||||
} |
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} |
||||
|
||||
func makeCoordinator() -> Coordinator { |
||||
Coordinator(completion: self.completion) |
||||
} |
||||
|
||||
class Coordinator: NSObject, MFMessageComposeViewControllerDelegate { |
||||
private let completion: Completion? |
||||
|
||||
public init(completion: Completion?) { |
||||
self.completion = completion |
||||
} |
||||
|
||||
public func messageComposeViewController(_ controller: MFMessageComposeViewController, didFinishWith result: MessageComposeResult) { |
||||
controller.dismiss(animated: true, completion: { |
||||
self.completion?(result) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
|
||||
struct MailComposeView: UIViewControllerRepresentable { |
||||
typealias Completion = (_ result: MFMailComposeResult) -> Void |
||||
|
||||
static var canSendMail: Bool { |
||||
if let mailURL = URL(string: "mailto:?to=jap@padelclub.com") { |
||||
let mailConfigured = UIApplication.shared.canOpenURL(mailURL) |
||||
return mailConfigured && MFMailComposeViewController.canSendMail() |
||||
} else { |
||||
return MFMailComposeViewController.canSendMail() |
||||
} |
||||
} |
||||
|
||||
let recipients: [String]? |
||||
let bccRecipients: [String]? |
||||
let body: String? |
||||
let subject: String? |
||||
var attachmentURL: URL? |
||||
let completion: Completion? |
||||
|
||||
func makeUIViewController(context: Context) -> UIViewController { |
||||
guard Self.canSendMail else { |
||||
let errorView = ContentUnavailableView("Aucun compte mail", systemImage: "xmark", description: Text("Aucun compte mail n'est configuré sur cet appareil.")) |
||||
return UIHostingController(rootView: errorView) |
||||
} |
||||
|
||||
let controller = MFMailComposeViewController() |
||||
controller.mailComposeDelegate = context.coordinator |
||||
controller.setToRecipients(recipients) |
||||
controller.setBccRecipients(bccRecipients) |
||||
if let attachmentURL { |
||||
do { |
||||
let attachmentData = try Data(contentsOf: attachmentURL) |
||||
controller.addAttachmentData(attachmentData, mimeType: "application/zip", fileName: "backup.zip") |
||||
} catch { |
||||
print("Could not attach file: \(error)") |
||||
} |
||||
} |
||||
|
||||
if let body { |
||||
controller.setMessageBody(body, isHTML: false) |
||||
} |
||||
if let subject { |
||||
controller.setSubject(subject) |
||||
} |
||||
|
||||
return controller |
||||
} |
||||
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {} |
||||
|
||||
func makeCoordinator() -> Coordinator { |
||||
Coordinator(completion: self.completion) |
||||
} |
||||
|
||||
class Coordinator: NSObject, MFMailComposeViewControllerDelegate { |
||||
private let completion: Completion? |
||||
|
||||
public init(completion: Completion?) { |
||||
self.completion = completion |
||||
} |
||||
|
||||
public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { |
||||
controller.dismiss(animated: true, completion: { |
||||
self.completion?(result) |
||||
}) |
||||
} |
||||
} |
||||
} |
||||
@ -1,29 +0,0 @@ |
||||
// |
||||
// DisplayContext.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 20/03/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum DisplayContext { |
||||
case addition |
||||
case edition |
||||
case lockedForEditing |
||||
case selection |
||||
} |
||||
|
||||
enum DisplayStyle { |
||||
case title |
||||
case wide |
||||
case short |
||||
} |
||||
|
||||
enum MatchViewStyle { |
||||
case standardStyle // vue normal |
||||
case sectionedStandardStyle // vue normal avec des sections indiquant déjà la manche |
||||
case feedStyle // vue programmation |
||||
case plainStyle // vue detail |
||||
case tournamentResultStyle //vue resultat tournoi |
||||
} |
||||
@ -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() |
||||
} |
||||
} |
||||
@ -1,12 +0,0 @@ |
||||
// |
||||
// Key.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Laurent Morvillier on 30/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
enum Key: String { |
||||
case pass = "Aa9QDV1G5MP9ijF2FTFasibNbS/Zun4qXrubIL2P+Ik=" |
||||
} |
||||
@ -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)") |
||||
} |
||||
} |
||||
} |
||||
@ -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" |
||||
} |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,83 @@ |
||||
// |
||||
// XlsToCsvService.swift |
||||
// PadelClub |
||||
// |
||||
// Created by razmig on 12/04/2025. |
||||
// |
||||
|
||||
import Foundation |
||||
import LeStorage |
||||
|
||||
class XlsToCsvService { |
||||
|
||||
static func exportToCsv(url: URL) async throws -> String { |
||||
let service = try StoreCenter.main.service() |
||||
var request = try service._baseRequest(servicePath: "xls-to-csv/", method: .post, requiresToken: true) |
||||
|
||||
// Create the boundary string for multipart/form-data |
||||
let boundary = UUID().uuidString |
||||
|
||||
// Set the content type to multipart/form-data with the boundary |
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") |
||||
|
||||
// The file to upload |
||||
let fileName = url.lastPathComponent |
||||
let fileURL = url |
||||
|
||||
// Construct the body of the request |
||||
var body = Data() |
||||
|
||||
// Start the body with the boundary and content-disposition for the file |
||||
body.append("--\(boundary)\r\n".data(using: .utf8)!) |
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!) |
||||
body.append("Content-Type: application/vnd.ms-excel\r\n\r\n".data(using: .utf8)!) |
||||
|
||||
// Append the file data |
||||
if let fileData = try? Data(contentsOf: fileURL) { |
||||
body.append(fileData) |
||||
} |
||||
|
||||
// End the body with the boundary |
||||
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) |
||||
|
||||
// Set the body of the request |
||||
request.httpBody = body |
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request) |
||||
|
||||
// Check the response status code |
||||
if let httpResponse = response as? HTTPURLResponse { |
||||
print("Status code: \(httpResponse.statusCode)") |
||||
} |
||||
|
||||
// Convert the response data to a String |
||||
if let responseString = String(data: data, encoding: .utf8) { |
||||
return responseString |
||||
} else { |
||||
let error = ErrorResponse(code: 1, status: "Encodage", error: "Encodage des données de classement invalide") |
||||
throw ConvertionError.serviceError(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
struct ErrorResponse: Decodable { |
||||
let code: Int |
||||
let status, error: String |
||||
} |
||||
|
||||
enum ConvertionError: LocalizedError { |
||||
case unknownError |
||||
case serviceError(ErrorResponse) |
||||
case urlNotFound(String) |
||||
|
||||
var errorDescription: String? { |
||||
switch self { |
||||
case .unknownError: |
||||
return "Erreur" |
||||
case .serviceError(let errorResponse): |
||||
return errorResponse.error |
||||
case .urlNotFound(let url): |
||||
return "L'URL [\(url)] n'est pas valide" |
||||
} |
||||
} |
||||
} |
||||
@ -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,119 +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 |
||||
|
||||
var id: String { |
||||
return "padelclub.app.patch.\(self.rawValue)" |
||||
} |
||||
} |
||||
|
||||
class Patcher { |
||||
|
||||
static func applyAllWhenApplicable() { |
||||
for patch in Patch.allCases { |
||||
self.patchIfPossible(patch) |
||||
} |
||||
} |
||||
|
||||
static func patchIfPossible(_ patch: Patch) { |
||||
if UserDefaults.standard.value(forKey: patch.id) == nil { |
||||
do { |
||||
Logger.log(">>> Patches \(patch.rawValue)...") |
||||
try self._applyPatch(patch) |
||||
UserDefaults.standard.setValue(true, forKey: patch.id) |
||||
} catch { |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
} |
||||
|
||||
fileprivate static func _applyPatch(_ patch: Patch) throws { |
||||
switch patch { |
||||
case .alexisLeDu: self._patchAlexisLeDu() |
||||
case .importDataFromDevToProd: try self._importDataFromDev() |
||||
} |
||||
} |
||||
|
||||
fileprivate static func _patchAlexisLeDu() { |
||||
guard StoreCenter.main.userId == "94f45ed2-8938-4c32-a4b6-e4525073dd33" else { return } |
||||
|
||||
let clubs = DataStore.shared.clubs |
||||
StoreCenter.main.resetApiCalls(collection: clubs) |
||||
// clubs.resetApiCalls() |
||||
|
||||
for club in clubs.filter({ $0.creator == "d5060b89-e979-4c19-bf78-e459a6ed5318"}) { |
||||
club.creator = StoreCenter.main.userId |
||||
clubs.writeChangeAndInsertOnServer(instance: club) |
||||
} |
||||
|
||||
} |
||||
|
||||
fileprivate static func _importDataFromDev() throws { |
||||
|
||||
let devServices = Services(url: "https://xlr.alwaysdata.net/roads/") |
||||
guard devServices.hasToken() else { |
||||
return |
||||
} |
||||
guard URLs.api.rawValue == "https://padelclub.app/roads/" else { |
||||
return |
||||
} |
||||
|
||||
guard let userId = StoreCenter.main.userId else { |
||||
return |
||||
} |
||||
|
||||
try StoreCenter.main.migrateToken(devServices) |
||||
|
||||
|
||||
let myClubs: [Club] = DataStore.shared.clubs.filter { $0.creator == userId } |
||||
let clubIds: [String] = myClubs.map { $0.id } |
||||
|
||||
myClubs.forEach { club in |
||||
DataStore.shared.clubs.insertIntoCurrentService(item: club) |
||||
|
||||
let courts = DataStore.shared.courts.filter { clubIds.contains($0.club) } |
||||
for court in courts { |
||||
DataStore.shared.courts.insertIntoCurrentService(item: court) |
||||
} |
||||
} |
||||
|
||||
DataStore.shared.user.clubs = Array(clubIds) |
||||
DataStore.shared.saveUser() |
||||
|
||||
DataStore.shared.events.insertAllIntoCurrentService() |
||||
DataStore.shared.tournaments.insertAllIntoCurrentService() |
||||
DataStore.shared.dateIntervals.insertAllIntoCurrentService() |
||||
|
||||
for tournament in DataStore.shared.tournaments { |
||||
let store = tournament.tournamentStore |
||||
|
||||
Task { // need to wait for the collections to load |
||||
try await Task.sleep(until: .now + .seconds(2)) |
||||
|
||||
store.teamRegistrations.insertAllIntoCurrentService() |
||||
store.rounds.insertAllIntoCurrentService() |
||||
store.groupStages.insertAllIntoCurrentService() |
||||
store.matches.insertAllIntoCurrentService() |
||||
store.playerRegistrations.insertAllIntoCurrentService() |
||||
store.teamScores.insertAllIntoCurrentService() |
||||
|
||||
} |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -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,257 +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(players: [FederalPlayer], sourceFileType: SourceFile, date: Date) { |
||||
let lastDateString = URL.importDateFormatter.string(from: date) |
||||
let dateString = ["CLASSEMENT-PADEL", sourceFileType.rawValue, lastDateString].joined(separator: "-") + "." + "csv" |
||||
|
||||
let documentsUrl:URL = (FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first as URL?)! |
||||
let destinationFileUrl = documentsUrl.appendingPathComponent("\(dateString)") |
||||
var csvText : String = "" |
||||
for player in players { |
||||
csvText.append(player.exportToCSV() + "\n") |
||||
} |
||||
|
||||
do { |
||||
try csvText.write(to: destinationFileUrl, atomically: true, encoding: .utf8) |
||||
print("CSV file exported successfully.") |
||||
} catch { |
||||
print("Error writing CSV file:", error) |
||||
Logger.error(error) |
||||
} |
||||
} |
||||
|
||||
actor SourceFileDownloadTracker { |
||||
var _downloadedFileStatus : Int? = nil |
||||
|
||||
func updateIfNecessary(with successState: Int?) { |
||||
if successState != nil && (_downloadedFileStatus == nil || _downloadedFileStatus == 0) { |
||||
_downloadedFileStatus = successState |
||||
} |
||||
} |
||||
|
||||
func getDownloadedFileStatus() -> Int? { |
||||
return _downloadedFileStatus |
||||
} |
||||
|
||||
} |
||||
|
||||
//return nil if no new files |
||||
//return 1 if new file to import |
||||
//return 0 if new file just to re-calc static data, no need to re-import |
||||
@discardableResult |
||||
func fetchData(fromDate current: Date) async -> Int? { |
||||
let lastStringDate = URL.importDateFormatter.string(from: current) |
||||
|
||||
let files = ["MESSIEURS", "MESSIEURS-2", "MESSIEURS-3", "MESSIEURS-4", "DAMES"] |
||||
|
||||
let sourceFileDownloadTracker = SourceFileDownloadTracker() |
||||
|
||||
do { |
||||
try await withThrowingTaskGroup(of: Void.self) { group in // Mark 1 |
||||
|
||||
for file in files { |
||||
group.addTask { [sourceFileDownloadTracker] in |
||||
let success = try await NetworkManager.shared.downloadRankingData(lastDateString: lastStringDate, fileName: file) |
||||
await sourceFileDownloadTracker.updateIfNecessary(with: success) |
||||
} |
||||
} |
||||
|
||||
try await group.waitForAll() |
||||
} |
||||
|
||||
// if current < Date() { |
||||
// if let nextCurrent = Calendar.current.date(byAdding: .month, value: 1, to: current) { |
||||
// await fetchData(fromDate: nextCurrent) |
||||
// } |
||||
// } |
||||
} catch { |
||||
print("downloadRankingData", error) |
||||
|
||||
if mostRecentDateAvailable == nil { |
||||
if let previousDate = Calendar.current.date(byAdding: .month, value: -1, to: current) { |
||||
await fetchData(fromDate: previousDate) |
||||
} |
||||
} |
||||
} |
||||
let downloadedFileStatus = await sourceFileDownloadTracker.getDownloadedFileStatus() |
||||
|
||||
return downloadedFileStatus |
||||
} |
||||
|
||||
func getAllFiles(initialDate: String = "08-2022") async { |
||||
let dates = monthsBetweenDates(startDateString: initialDate, endDateString: Date().monthYearFormatted) |
||||
.compactMap { |
||||
URL.importDateFormatter.date(from: $0) |
||||
} |
||||
.filter { date in |
||||
allFiles.contains(where: { $0.dateFromPath == date }) == false |
||||
} |
||||
|
||||
try? await dates.concurrentForEach { date in |
||||
await self.fetchData(fromDate: date) |
||||
} |
||||
} |
||||
|
||||
func monthsBetweenDates(startDateString: String, endDateString: String) -> [String] { |
||||
let dateFormatter = URL.importDateFormatter |
||||
|
||||
guard let startDate = dateFormatter.date(from: startDateString), |
||||
let endDate = dateFormatter.date(from: endDateString) else { |
||||
return [] |
||||
} |
||||
|
||||
var months: [String] = [] |
||||
var currentDate = startDate |
||||
let calendar = Calendar.current |
||||
|
||||
while currentDate <= endDate { |
||||
let monthString = dateFormatter.string(from: currentDate) |
||||
months.append(monthString) |
||||
|
||||
guard let nextMonthDate = calendar.date(byAdding: .month, value: 1, to: currentDate) else { |
||||
break |
||||
} |
||||
currentDate = nextMonthDate |
||||
} |
||||
return months |
||||
} |
||||
|
||||
func getUnrankValue(forMale: Bool, rankSourceDate: Date?) -> Int? { |
||||
let _rankSourceDate = rankSourceDate ?? mostRecentDateAvailable |
||||
let urls = allFiles(forMale).filter { $0.dateFromPath == _rankSourceDate } |
||||
return urls.compactMap { $0.getUnrankedValue() }.sorted().last |
||||
} |
||||
|
||||
var mostRecentDateAvailable: Date? { |
||||
allFiles(false).first?.dateFromPath |
||||
} |
||||
|
||||
func removeAllFilesFromServer() { |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil) |
||||
allFiles.filter { $0.pathExtension == "csv" }.forEach { url in |
||||
try? FileManager.default.removeItem(at: url) |
||||
} |
||||
} |
||||
|
||||
func jsonFiles() -> [URL] { |
||||
let allJSONFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in |
||||
url.pathExtension == "json" |
||||
}) |
||||
return allJSONFiles |
||||
} |
||||
|
||||
var allFiles: [URL] { |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil).filter({ url in |
||||
url.pathExtension == "csv" |
||||
}) |
||||
|
||||
return (allFiles + (Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil) ?? [])).sorted { $0.dateFromPath == $1.dateFromPath ? $0.index < $1.index : $0.dateFromPath > $1.dateFromPath } |
||||
} |
||||
|
||||
func allFiles(_ isManPlayer: Bool) -> [URL] { |
||||
allFiles.filter({ url in |
||||
url.path().contains(isManPlayer ? SourceFile.messieurs.rawValue : SourceFile.dames.rawValue) |
||||
}) |
||||
} |
||||
|
||||
func allFilesSortedByDate(_ isManPlayer: Bool) -> [URL] { |
||||
return allFiles(isManPlayer) |
||||
} |
||||
|
||||
static func isDateAfterUrlImportDate(date: Date, dateString: String) -> Bool { |
||||
guard let importDate = URL.importDateFormatter.date(from: dateString) else { |
||||
return false |
||||
} |
||||
|
||||
return importDate.isEarlierThan(date) |
||||
} |
||||
} |
||||
|
||||
enum SourceFile: String, CaseIterable { |
||||
case dames = "DAMES" |
||||
case messieurs = "MESSIEURS" |
||||
|
||||
var filesFromServer: [URL] { |
||||
let rankingSourceDirectory = SourceFileManager.shared.rankingSourceDirectory |
||||
let allFiles = try! FileManager.default.contentsOfDirectory(at: rankingSourceDirectory, includingPropertiesForKeys: nil) |
||||
return allFiles.filter{$0.pathExtension == "csv" && $0.path().contains(rawValue)} |
||||
} |
||||
|
||||
func currentURLs(importingDate: Date) -> [URL] { |
||||
var files = Bundle.main.urls(forResourcesWithExtension: "csv", subdirectory: nil)?.filter({ url in |
||||
url.path().contains(rawValue) |
||||
}) ?? [] |
||||
|
||||
files.append(contentsOf: filesFromServer) |
||||
return files.filter({ $0.dateFromPath == importingDate }) |
||||
} |
||||
|
||||
var isMan: Bool { |
||||
switch self { |
||||
case .dames: |
||||
return false |
||||
default: |
||||
return true |
||||
} |
||||
} |
||||
} |
||||
@ -1,80 +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/" |
||||
#else |
||||
case activationHost = "padelclub.app" |
||||
case main = "https://padelclub.app/" |
||||
case api = "https://padelclub.app/roads/" |
||||
#endif |
||||
case subscriptions = "https://apple.co/2Th4vqI" |
||||
case beachPadel = "https://beach-padel.app.fft.fr/beachja/index/" |
||||
//case padelClub = "https://padelclub.app" |
||||
case tenup = "https://tenup.fft.fr" |
||||
case padelCompetitionGeneralGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi2PB5LeNNTxlrS_1-REGLESGENERALESDELACOMPETITION-ANNEESPORTIVE2025.pdf" |
||||
case padelCompetitionSpecificGuide = "https://fft-site.cdn.prismic.io/fft-site/Zqi4ax5LeNNTxlsu_3-CAHIERDESCHARGESDESTOURNOIS-ANNEESPORTIVE2025.pdf" |
||||
case padelRules = "https://xlr.alwaysdata.net/static/rules/padel-rules-2024.pdf" |
||||
case restingDischarge = "https://club.fft.fr/tennisfirmidecazeville/60120370_d/data_1/pdf/fo/formlairededechargederesponsabilitetournoidepadel.pdf" |
||||
case appReview = "https://apps.apple.com/app/padel-club/id6484163558?mt=8&action=write-review" |
||||
case appDescription = "https://padelclub.app/download/" |
||||
case instagram = "https://www.instagram.com/padelclub.app?igsh=bmticnV5YWhpMnBn" |
||||
case appStore = "https://apps.apple.com/app/padel-club/id6484163558" |
||||
|
||||
var id: String { return self.rawValue } |
||||
|
||||
var url: URL { |
||||
return URL(string: self.rawValue)! |
||||
} |
||||
|
||||
static func sitePage(component: String) -> String { |
||||
return "\(URLs.main.rawValue)\(component)" |
||||
} |
||||
|
||||
} |
||||
|
||||
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 |
||||
} |
||||
|
||||
} |
||||
@ -1,101 +0,0 @@ |
||||
// |
||||
// MatchDescriptor.swift |
||||
// PadelClub |
||||
// |
||||
// Created by Razmig Sarkissian on 02/04/2024. |
||||
// |
||||
|
||||
import Foundation |
||||
|
||||
class MatchDescriptor: ObservableObject { |
||||
@Published var matchFormat: MatchFormat |
||||
@Published var setDescriptors: [SetDescriptor] |
||||
var court: Int = 1 |
||||
var title: String = "Titre du match" |
||||
var teamLabelOne: String = "" |
||||
var teamLabelTwo: String = "" |
||||
var startDate: Date = Date() |
||||
var match: Match? |
||||
|
||||
init(match: Match? = nil) { |
||||
self.match = match |
||||
if let groupStage = match?.groupStageObject { |
||||
self.matchFormat = groupStage.matchFormat |
||||
self.setDescriptors = [SetDescriptor(setFormat: groupStage.matchFormat.setFormat)] |
||||
} else { |
||||
let format = match?.matchFormat ?? match?.currentTournament()?.matchFormat ?? .defaultFormatForMatchType(.groupStage) |
||||
self.matchFormat = format |
||||
self.setDescriptors = [SetDescriptor(setFormat: format.setFormat)] |
||||
} |
||||
let teamOne = match?.team(.one) |
||||
let teamTwo = match?.team(.two) |
||||
self.teamLabelOne = teamOne?.teamLabel(.wide, twoLines: true) ?? "" |
||||
self.teamLabelTwo = teamTwo?.teamLabel(.wide, twoLines: true) ?? "" |
||||
|
||||
if let match, let scoresTeamOne = match.teamScore(ofTeam: teamOne)?.score, let scoresTeamTwo = match.teamScore(ofTeam: teamTwo)?.score { |
||||
|
||||
self.setDescriptors = combineArraysIntoTuples(scoresTeamOne.components(separatedBy: ","), scoresTeamTwo.components(separatedBy: ",")).map({ (a:String?, b:String?) in |
||||
SetDescriptor(valueTeamOne: a != nil ? Int(a!) : nil, valueTeamTwo: b != nil ? Int(b!) : nil, setFormat: match.matchFormat.setFormat) |
||||
}) |
||||
} |
||||
} |
||||
|
||||
var teamOneScores: [String] { |
||||
setDescriptors.compactMap { $0.valueTeamOne }.map { "\($0)" } |
||||
} |
||||
|
||||
var teamTwoScores: [String] { |
||||
setDescriptors.compactMap { $0.valueTeamTwo }.map { "\($0)" } |
||||
} |
||||
|
||||
var scoreTeamOne: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .one }.count } |
||||
var scoreTeamTwo: Int { setDescriptors.compactMap { $0.winner }.filter { $0 == .two }.count } |
||||
|
||||
var hasEnded: Bool { |
||||
return matchFormat.hasEnded(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) |
||||
} |
||||
|
||||
func addNewSet() { |
||||
if hasEnded == false { |
||||
setDescriptors.append(SetDescriptor(setFormat: matchFormat.newSetFormat(setCount: setDescriptors.count))) |
||||
} |
||||
} |
||||
|
||||
var winner: TeamPosition { |
||||
matchFormat.winner(scoreTeamOne: scoreTeamOne, scoreTeamTwo: scoreTeamTwo) |
||||
} |
||||
|
||||
var winnerLabel: String { |
||||
if winner == .one { |
||||
return teamLabelOne |
||||
} else { |
||||
return teamLabelTwo |
||||
} |
||||
} |
||||
} |
||||
|
||||
fileprivate func combineArraysIntoTuples(_ array1: [String], _ array2: [String]) -> [(String?, String?)] { |
||||
// Zip the two arrays together and map them to tuples of optional strings |
||||
let combined = zip(array1, array2).map { (element1, element2) in |
||||
return (element1, element2) |
||||
} |
||||
|
||||
// If one array is longer than the other, append the remaining elements |
||||
let remainingElements: [(String?, String?)] |
||||
if array1.count > array2.count { |
||||
let remaining = Array(array1[array2.count...]).map { (element) in |
||||
return (element, nil as String?) |
||||
} |
||||
remainingElements = remaining |
||||
} else if array2.count > array1.count { |
||||
let remaining = Array(array2[array1.count...]).map { (element) in |
||||
return (nil as String?, element) |
||||
} |
||||
remainingElements = remaining |
||||
} else { |
||||
remainingElements = [] |
||||
} |
||||
|
||||
// Concatenate the two arrays |
||||
return combined + remainingElements |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue