AnayShukla commited on
Commit
f7cecf3
·
0 Parent(s):

Clean Production Release

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +2 -0
  2. .github/workflows/keep_alive.yml +22 -0
  3. .github/workflows/sync.yml +17 -0
  4. .gitignore +48 -0
  5. Dockerfile +18 -0
  6. README.md +23 -0
  7. admin_fixture_overrides.json +11 -0
  8. admin_persistent_availability_multipliers.json +555 -0
  9. admin_persistent_share_overrides.json +1 -0
  10. admin_persistent_xmins_overrides.json +753 -0
  11. auth.py +205 -0
  12. database.py +50 -0
  13. engine.py +613 -0
  14. ewmapois_model.csv +72 -0
  15. fpl_api.py +88 -0
  16. fpl_streamlit_app.py +0 -0
  17. frontend/.gitignore +24 -0
  18. frontend/README.md +16 -0
  19. frontend/eslint.config.js +29 -0
  20. frontend/index.html +13 -0
  21. frontend/package-lock.json +0 -0
  22. frontend/package.json +41 -0
  23. frontend/postcss.config.js +6 -0
  24. frontend/public/favicon.svg +1 -0
  25. frontend/public/hero.png +0 -0
  26. frontend/public/icon.jpg +0 -0
  27. frontend/public/icons.svg +24 -0
  28. frontend/public/image.png +0 -0
  29. frontend/public/l-logo.png +0 -0
  30. frontend/public/luigismansion.jpg +0 -0
  31. frontend/src/App.css +184 -0
  32. frontend/src/App.jsx +280 -0
  33. frontend/src/PlayerContext.jsx +572 -0
  34. frontend/src/assets/react.svg +1 -0
  35. frontend/src/assets/vite.svg +1 -0
  36. frontend/src/components/AccuracyDashboard.jsx +273 -0
  37. frontend/src/components/ActiveMovesPanel.jsx +94 -0
  38. frontend/src/components/AdvancedSettingsModal.jsx +254 -0
  39. frontend/src/components/DraftsComparisonTable.jsx +140 -0
  40. frontend/src/components/DraggablePlayer.jsx +72 -0
  41. frontend/src/components/FixtureMatrixPanel.jsx +375 -0
  42. frontend/src/components/Fixtures.jsx +143 -0
  43. frontend/src/components/LandingPage.jsx +76 -0
  44. frontend/src/components/LoginModal.jsx +231 -0
  45. frontend/src/components/PitchView.jsx +100 -0
  46. frontend/src/components/PlayerCardVisual.jsx +258 -0
  47. frontend/src/components/PlayerModals.jsx +286 -0
  48. frontend/src/components/ProjectionsTable.jsx +664 -0
  49. frontend/src/components/Solver.jsx +1806 -0
  50. frontend/src/components/SolverOutputPanel.jsx +201 -0
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.xlsx filter=lfs diff=lfs merge=lfs -text
2
+ *.ttf filter=lfs diff=lfs merge=lfs -text
.github/workflows/keep_alive.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Keep App Alive
2
+
3
+ on:
4
+ schedule:
5
+ - cron: '0 */8 * * *'
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ ping-repo:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: write # Critical: Grants the action permission to push to your repo
13
+ steps:
14
+ - name: Checkout repository
15
+ uses: actions/checkout@v4
16
+
17
+ - name: Push Empty Commit
18
+ run: |
19
+ git config --global user.name "GitHub Actions"
20
+ git config --global user.email "actions@github.com"
21
+ git commit --allow-empty -m "Auto-commit to keep app awake"
22
+ git push
.github/workflows/sync.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Sync to Hugging Face
2
+ on:
3
+ push:
4
+ branches: [main, master]
5
+ jobs:
6
+ sync-to-hub:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ with:
11
+ fetch-depth: 0
12
+ lfs: true
13
+ - name: Push to Hugging Face
14
+ env:
15
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
16
+ # BE SURE TO REPLACE THE TWO PLACEHOLDERS BELOW!
17
+ run: git push --force https://AnayShukla:${HF_TOKEN}@huggingface.co/spaces/AnayShukla/fpl-solver HEAD:main
.gitignore ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *
2
+
3
+ !fpl_streamlit_app.py
4
+ !statistical_weighted_baselines.csv
5
+ !statistical_weighted_baselines_gk.csv
6
+ !admin_persistent_xmins_overrides.json
7
+ !admin_persistent_share_overrides.json
8
+ !admin_persistent_availability_multipliers.json
9
+ !ewmapois_model.csv
10
+ !team_totals.xlsx
11
+ !user_xmins_overrides.json
12
+ !user_player_status_overrides.json
13
+ !user_baseline_overrides.json
14
+ !player_penalty_shares.json
15
+ !player_groups.json
16
+ !rename.json
17
+ !rates_config.json
18
+ !requirements.txt
19
+ !README.md
20
+ !.gitignore
21
+ !.github
22
+ !.github/workflows
23
+ !.github/workflows/*
24
+ !projections_check.xlsx
25
+ !points_check.xlsx
26
+ !logos/
27
+ !logos/*
28
+ !team_ratings_dual_speed.csv
29
+ !frontend/*
30
+ !frontend
31
+ !frontend/src
32
+ !frontend/src/*
33
+ !frontend/src/*/*
34
+ !frontend/public
35
+ !frontend/public/*
36
+ !main.py
37
+ !engine.py
38
+ !database.py
39
+ !fpl_api.py
40
+ !auth.py
41
+ !solver.py
42
+ !solver_engine.py
43
+ !admin_fixture_overrides.json
44
+ !Dockerfile
45
+ !.gitattributes
46
+
47
+
48
+ !LICENSE
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Use a lightweight Python x86 image
2
+ FROM python:3.10-slim
3
+
4
+ # Set the working directory inside the server
5
+ WORKDIR /app
6
+
7
+ # Copy your requirements first and install them
8
+ COPY requirements.txt .
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ # Copy all your Python code, CSVs, and logic into the server
12
+ COPY . .
13
+
14
+ # Hugging Face REQUIRES your app to run on port 7860
15
+ EXPOSE 7860
16
+
17
+ # The command to boot the server
18
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Luigi's Mansion FPL Solver
3
+ emoji: 👻
4
+ colorFrom: green
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+
11
+
12
+ ## Luigi's Mansion
13
+ Yes you can play around with xMins here (I made it more for my convenience rather than yours but nonetheless, enjoy!)
14
+ Luigi attempts a thing! I have finally managed to make my very own player model, think it might be subpar to the great ones but it's a start at least and I am relatively happy with that. Hope you all have a fun time tinkering!
15
+
16
+ ### Instructions:
17
+
18
+ - Use the sidebar to adjust player minutes, weekly or across the horizon.
19
+ - Please wait 1-2s after pressing the Update button as your changes get processed and updated.
20
+ - Currently, the overrides CANNOT be applied simultaneously, so don't forget to press the Update button else your changes will not be processed.
21
+ - The Reset Override button ONLY resets the xMins of the player chosen. To revert back to original projections, simply reload the page.
22
+ - The CSV is compatible with Sertalp + Moose's Solver, you can either rename the file to 'fplreview' or set 'luigis_mansion' as the datasource in the settings json.
23
+ - You can download your customized projections as a CSV file (will be downloaded as 'luigis_mansion.csv').
admin_fixture_overrides.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "3_vs_13": {
3
+ "33": 1
4
+ },
5
+ "6_vs_7": {
6
+ "33": 1
7
+ },
8
+ "4_vs_11": {
9
+ "33": 1
10
+ }
11
+ }
admin_persistent_availability_multipliers.json ADDED
@@ -0,0 +1,555 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "17": {
3
+ "8": 0.0,
4
+ "9": 0.0,
5
+ "10": 0.0,
6
+ "11": 0.0,
7
+ "12": 0.5
8
+ },
9
+ "173": {
10
+ "27": 0.5
11
+ },
12
+ "642": {
13
+ "8": 0.75,
14
+ "28": 0.0
15
+ },
16
+ "200": {
17
+ "8": 0.75
18
+ },
19
+ "596": {
20
+ "31": 0.6
21
+ },
22
+ "217": {
23
+ "8": 0.75
24
+ },
25
+ "237": {
26
+ "8": 0.75
27
+ },
28
+ "525": {
29
+ "8": 0.75
30
+ },
31
+ "316": {
32
+ "8": 0.0,
33
+ "9": 0.0,
34
+ "10": 0.0,
35
+ "11": 0.0,
36
+ "12": 0.0,
37
+ "13": 0.0,
38
+ "14": 0.0,
39
+ "15": 0.25
40
+ },
41
+ "30": {
42
+ "8": 0.0,
43
+ "9": 0.0,
44
+ "10": 0.0,
45
+ "11": 0.25,
46
+ "12": 0.5,
47
+ "13": 0.75,
48
+ "28": 0.6,
49
+ "29": 0.8
50
+ },
51
+ "31": {
52
+ "8": 0.0,
53
+ "9": 0.0,
54
+ "10": 0.0,
55
+ "11": 0.0,
56
+ "12": 0.0,
57
+ "13": 0.0,
58
+ "14": 0.0,
59
+ "15": 0.0,
60
+ "16": 0.0,
61
+ "17": 0.0,
62
+ "19": 1.0
63
+ },
64
+ "151": {
65
+ "4": 0.75
66
+ },
67
+ "232": {
68
+ "8": 0.75
69
+ },
70
+ "230": {
71
+ "28": 0.0
72
+ },
73
+ "135": {
74
+ "8": 0.0,
75
+ "9": 0.0,
76
+ "10": 0.5
77
+ },
78
+ "402": {
79
+ "8": 0.25,
80
+ "9": 0.5
81
+ },
82
+ "419": {
83
+ "17": 0.25,
84
+ "18": 1.0
85
+ },
86
+ "411": {
87
+ "29": 0.7
88
+ },
89
+ "235": {
90
+ "4": 0.5,
91
+ "6": 0.0,
92
+ "7": 0.0,
93
+ "8": 0.0,
94
+ "9": 0.0,
95
+ "10": 0.0,
96
+ "11": 0.25,
97
+ "12": 0.5,
98
+ "22": 0.93
99
+ },
100
+ "64": {
101
+ "24": 0.4
102
+ },
103
+ "712": {
104
+ "4": 0.75,
105
+ "6": 0.75
106
+ },
107
+ "293": {
108
+ "27": 0.0
109
+ },
110
+ "403": {
111
+ "4": 0.5,
112
+ "5": 1.0
113
+ },
114
+ "450": {
115
+ "4": 0.75,
116
+ "13": 0.0,
117
+ "14": 0.75
118
+ },
119
+ "508": {
120
+ "25": 0.0
121
+ },
122
+ "490": {
123
+ "4": 0.5
124
+ },
125
+ "654": {
126
+ "4": 0.75,
127
+ "5": 0.75
128
+ },
129
+ "299": {
130
+ "4": 0.5,
131
+ "23": 0.9
132
+ },
133
+ "48": {
134
+ "6": 0.5,
135
+ "5": 0.5,
136
+ "7": 0.0,
137
+ "8": 0.0,
138
+ "9": 0.25,
139
+ "10": 0.5,
140
+ "11": 0.5,
141
+ "12": 1.0
142
+ },
143
+ "488": {
144
+ "23": 0.8
145
+ },
146
+ "685": {
147
+ "5": 0.75
148
+ },
149
+ "85": {
150
+ "31": 0.0
151
+ },
152
+ "145": {
153
+ "5": 0.75
154
+ },
155
+ "506": {
156
+ "5": 0.5,
157
+ "6": 0.25,
158
+ "7": 0.5,
159
+ "13": 0.75
160
+ },
161
+ "552": {
162
+ "5": 0.75
163
+ },
164
+ "547": {
165
+ "31": 0.7
166
+ },
167
+ "16": {
168
+ "25": 0.0,
169
+ "26": 0.0
170
+ },
171
+ "457": {
172
+ "17": 0.0
173
+ },
174
+ "249": {
175
+ "6": 0.75
176
+ },
177
+ "32": {
178
+ "7": 0.25
179
+ },
180
+ "40": {
181
+ "8": 0.75,
182
+ "7": 0.0
183
+ },
184
+ "152": {
185
+ "7": 0.5
186
+ },
187
+ "157": {
188
+ "7": 0.25,
189
+ "8": 0.75,
190
+ "10": 0.75,
191
+ "9": 0.5,
192
+ "11": 0.0,
193
+ "12": 0.0,
194
+ "13": 0.25,
195
+ "14": 0.0,
196
+ "15": 0.5,
197
+ "16": 0.75,
198
+ "19": 0.5,
199
+ "31": 0.9
200
+ },
201
+ "226": {
202
+ "7": 0.0,
203
+ "14": 0.25
204
+ },
205
+ "242": {
206
+ "7": 0.0,
207
+ "23": 0.8
208
+ },
209
+ "337": {
210
+ "30": 0.5
211
+ },
212
+ "332": {
213
+ "8": 0.0,
214
+ "9": 0.0,
215
+ "10": 0.5
216
+ },
217
+ "338": {
218
+ "8": 0.0,
219
+ "9": 0.0,
220
+ "10": 0.5
221
+ },
222
+ "97": {
223
+ "9": 0.0,
224
+ "10": 0.5,
225
+ "11": 1.0
226
+ },
227
+ "317": {
228
+ "9": 0.0,
229
+ "10": 1.0,
230
+ "11": 1.0
231
+ },
232
+ "329": {
233
+ "29": 0.65,
234
+ "30": 0.7
235
+ },
236
+ "413": {
237
+ "9": 0.5,
238
+ "16": 0.5
239
+ },
240
+ "5": {
241
+ "9": 0.5,
242
+ "10": 1.0,
243
+ "12": 0.25,
244
+ "13": 0.5,
245
+ "18": 0.25,
246
+ "19": 0.9
247
+ },
248
+ "6": {
249
+ "10": 0.0,
250
+ "11": 1.0,
251
+ "14": 0.0,
252
+ "15": 0.25,
253
+ "16": 0.5
254
+ },
255
+ "381": {
256
+ "31": 0.0,
257
+ "32": 0.8
258
+ },
259
+ "382": {
260
+ "28": 0.45,
261
+ "29": 0.4,
262
+ "30": 0.65
263
+ },
264
+ "666": {
265
+ "11": 0.0,
266
+ "12": 0.5,
267
+ "13": 0.25,
268
+ "14": 0.5
269
+ },
270
+ "691": {
271
+ "30": 0.75
272
+ },
273
+ "612": {
274
+ "12": 0.0,
275
+ "21": 0.0,
276
+ "22": 0.75
277
+ },
278
+ "82": {
279
+ "13": 1.0,
280
+ "12": 0.75,
281
+ "30": 0.8
282
+ },
283
+ "476": {
284
+ "12": 0.0
285
+ },
286
+ "375": {
287
+ "12": 0.0,
288
+ "13": 0.0,
289
+ "14": 0.0
290
+ },
291
+ "302": {
292
+ "13": 0.0,
293
+ "14": 0.0,
294
+ "15": 0.0,
295
+ "16": 0.0,
296
+ "17": 0.0,
297
+ "18": 0.0,
298
+ "19": 0.0
299
+ },
300
+ "661": {
301
+ "13": 0.5,
302
+ "21": 0.45,
303
+ "22": 1.0
304
+ },
305
+ "515": {
306
+ "13": 0.75
307
+ },
308
+ "565": {
309
+ "31": 0.0
310
+ },
311
+ "569": {
312
+ "13": 0.0,
313
+ "18": 0.0,
314
+ "26": 0.0,
315
+ "27": 0.0,
316
+ "28": 0.0,
317
+ "29": 0.0,
318
+ "30": 0.0
319
+ },
320
+ "72": {
321
+ "14": 0.0,
322
+ "16": 0.75
323
+ },
324
+ "267": {
325
+ "14": 0.0,
326
+ "15": 0.0,
327
+ "16": 0.75
328
+ },
329
+ "241": {
330
+ "14": 0.0,
331
+ "15": 0.0,
332
+ "16": 0.0
333
+ },
334
+ "469": {
335
+ "14": 0.0,
336
+ "15": 0.0
337
+ },
338
+ "660": {
339
+ "14": 0.0,
340
+ "15": 0.5,
341
+ "23": 0.8,
342
+ "25": 0.0,
343
+ "26": 0.0
344
+ },
345
+ "236": {
346
+ "25": 0.6,
347
+ "26": 0.95,
348
+ "30": 0.0
349
+ },
350
+ "554": {
351
+ "14": 0.0,
352
+ "15": 0.0
353
+ },
354
+ "295": {
355
+ "15": 0.25,
356
+ "16": 1.0,
357
+ "20": 0.75,
358
+ "22": 0.0,
359
+ "23": 0.0
360
+ },
361
+ "256": {
362
+ "16": 0.75,
363
+ "30": 0.8
364
+ },
365
+ "257": {
366
+ "29": 0.0
367
+ },
368
+ "384": {
369
+ "16": 0.0,
370
+ "17": 0.0,
371
+ "18": 0.5,
372
+ "23": 0.8
373
+ },
374
+ "8": {
375
+ "19": 0.75,
376
+ "30": 0.98
377
+ },
378
+ "120": {
379
+ "16": 0.0,
380
+ "25": 0.0,
381
+ "26": 0.0
382
+ },
383
+ "365": {
384
+ "16": 0.0,
385
+ "17": 0.0
386
+ },
387
+ "456": {
388
+ "17": 0.0
389
+ },
390
+ "124": {
391
+ "17": 0.75
392
+ },
393
+ "146": {
394
+ "17": 0.0
395
+ },
396
+ "169": {
397
+ "17": 0.0
398
+ },
399
+ "694": {
400
+ "28": 0.75
401
+ },
402
+ "178": {
403
+ "17": 0.5
404
+ },
405
+ "387": {
406
+ "18": 0.0,
407
+ "26": 0.0
408
+ },
409
+ "449": {
410
+ "18": 0.0,
411
+ "19": 0.0,
412
+ "20": 0.0,
413
+ "21": 0.34,
414
+ "22": 1.0
415
+ },
416
+ "20": {
417
+ "26": 0.6
418
+ },
419
+ "347": {
420
+ "23": 0.9,
421
+ "31": 0.0
422
+ },
423
+ "717": {
424
+ "18": 0.0,
425
+ "19": 0.0,
426
+ "20": 0.0
427
+ },
428
+ "19": {
429
+ "18": 0.0
430
+ },
431
+ "261": {
432
+ "18": 0.5,
433
+ "19": 0.36,
434
+ "20": 0.62
435
+ },
436
+ "374": {
437
+ "18": 0.5
438
+ },
439
+ "7": {
440
+ "20": 0.0,
441
+ "21": 0.2,
442
+ "22": 0.6,
443
+ "23": 0.6
444
+ },
445
+ "36": {
446
+ "31": 0.7
447
+ },
448
+ "58": {
449
+ "19": 0.0
450
+ },
451
+ "643": {
452
+ "19": 0.0,
453
+ "26": 0.7
454
+ },
455
+ "113": {
456
+ "19": 0.75
457
+ },
458
+ "455": {
459
+ "19": 0.65,
460
+ "20": 0.6,
461
+ "21": 0.5
462
+ },
463
+ "21": {
464
+ "20": 0.0,
465
+ "21": 1.0,
466
+ "29": 0.7
467
+ },
468
+ "354": {
469
+ "20": 0.0
470
+ },
471
+ "670": {
472
+ "29": 0.0,
473
+ "30": 0.0
474
+ },
475
+ "673": {
476
+ "30": 0.0
477
+ },
478
+ "796": {
479
+ "30": 0.7
480
+ },
481
+ "531": {
482
+ "20": 0.46,
483
+ "31": 0.8
484
+ },
485
+ "220": {
486
+ "21": 0.6
487
+ },
488
+ "322": {
489
+ "21": 0.23,
490
+ "22": 0.6,
491
+ "24": 0.8,
492
+ "25": 0.9,
493
+ "31": 0.75
494
+ },
495
+ "342": {
496
+ "21": 0.35
497
+ },
498
+ "224": {
499
+ "21": 0.33,
500
+ "27": 0.0,
501
+ "28": 0.3,
502
+ "29": 0.5
503
+ },
504
+ "582": {
505
+ "21": 0.3,
506
+ "22": 0.7
507
+ },
508
+ "12": {
509
+ "22": 0.0
510
+ },
511
+ "260": {
512
+ "22": 0.0
513
+ },
514
+ "84": {
515
+ "23": 0.0,
516
+ "24": 0.0,
517
+ "25": 0.0,
518
+ "28": 0.45,
519
+ "29": 0.92
520
+ },
521
+ "283": {
522
+ "24": 0.0
523
+ },
524
+ "407": {
525
+ "23": 0.3
526
+ },
527
+ "414": {
528
+ "23": 0.86
529
+ },
530
+ "430": {
531
+ "23": 0.91,
532
+ "29": 0.8
533
+ },
534
+ "108": {
535
+ "24": 0.75
536
+ },
537
+ "121": {
538
+ "24": 0.75
539
+ },
540
+ "290": {
541
+ "24": 0.3
542
+ },
543
+ "575": {
544
+ "30": 0.0
545
+ },
546
+ "709": {
547
+ "24": 0.5
548
+ },
549
+ "807": {
550
+ "24": 0.5
551
+ },
552
+ "609": {
553
+ "25": 0.0
554
+ }
555
+ }
admin_persistent_share_overrides.json ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
admin_persistent_xmins_overrides.json ADDED
@@ -0,0 +1,753 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "1": {
3
+ "1": 88.19
4
+ },
5
+ "33": {
6
+ "1": 87.36,
7
+ "7": 88.77
8
+ },
9
+ "338": {
10
+ "30": 72.0
11
+ },
12
+ "81": {
13
+ "1": 22.34
14
+ },
15
+ "101": {
16
+ "1": 87.57
17
+ },
18
+ "596": {
19
+ "1": 33.87
20
+ },
21
+ "136": {
22
+ "1": 82.3
23
+ },
24
+ "5": {
25
+ "1": 76.18
26
+ },
27
+ "120": {
28
+ "1": 53.13
29
+ },
30
+ "597": {
31
+ "1": 73.0
32
+ },
33
+ "41": {
34
+ "2": 82.54,
35
+ "7": 78.37
36
+ },
37
+ "235": {
38
+ "18": 75.0,
39
+ "19": 40.0,
40
+ "20": 74.0
41
+ },
42
+ "238": {
43
+ "3": 56.55,
44
+ "6_vs_7": 63.0
45
+ },
46
+ "16": {
47
+ "3": 0.0,
48
+ "7": 78.73,
49
+ "32": 52.0
50
+ },
51
+ "17": {
52
+ "3": 0.0,
53
+ "32": 61.0,
54
+ "33": 64.0
55
+ },
56
+ "474": {
57
+ "3": 65.2
58
+ },
59
+ "491": {
60
+ "3": 28.37
61
+ },
62
+ "654": {
63
+ "3": 55.47
64
+ },
65
+ "18": {
66
+ "3": 78.04,
67
+ "4": 77.04,
68
+ "5": 75.53,
69
+ "25": 85.0,
70
+ "26": 83.0
71
+ },
72
+ "271": {
73
+ "4": 77.03,
74
+ "5": 75.09
75
+ },
76
+ "341": {
77
+ "4": 87.0,
78
+ "5": 86.0
79
+ },
80
+ "665": {
81
+ "4": 0.0,
82
+ "5": 0.0
83
+ },
84
+ "691": {
85
+ "5": 75.35,
86
+ "4": 78.54,
87
+ "6": 67.43
88
+ },
89
+ "733": {
90
+ "4": 0.0
91
+ },
92
+ "430": {
93
+ "30": 74.0
94
+ },
95
+ "431": {
96
+ "4": 86.43
97
+ },
98
+ "456": {
99
+ "6": 42.43,
100
+ "32": 12.0
101
+ },
102
+ "677": {
103
+ "17": 0.0,
104
+ "18": 0.0,
105
+ "19": 0.0,
106
+ "20": 0.0,
107
+ "21": 0.0
108
+ },
109
+ "697": {
110
+ "17": 0.0,
111
+ "18": 0.0,
112
+ "19": 0.0,
113
+ "20": 0.0,
114
+ "21": 0.0
115
+ },
116
+ "83": {
117
+ "17": 0.0,
118
+ "18": 0.0,
119
+ "19": 0.0,
120
+ "20": 0.0,
121
+ "21": 0.0
122
+ },
123
+ "167": {
124
+ "17": 0.0,
125
+ "18": 0.0,
126
+ "19": 0.0,
127
+ "20": 0.0,
128
+ "21": 0.0
129
+ },
130
+ "198": {
131
+ "17": 0.0,
132
+ "18": 0.0,
133
+ "19": 0.0,
134
+ "20": 0.0,
135
+ "21": 0.0
136
+ },
137
+ "207": {
138
+ "17": 0.0,
139
+ "18": 0.0,
140
+ "19": 0.0,
141
+ "20": 0.0,
142
+ "21": 0.0
143
+ },
144
+ "217": {
145
+ "17": 0.0,
146
+ "18": 0.0,
147
+ "19": 0.0,
148
+ "20": 0.0,
149
+ "21": 0.0
150
+ },
151
+ "266": {
152
+ "8": 80,
153
+ "9": 79,
154
+ "10": 78,
155
+ "11": 75,
156
+ "12": 73,
157
+ "32": 34.0
158
+ },
159
+ "267": {
160
+ "17": 0.0,
161
+ "18": 0.0,
162
+ "19": 0.0,
163
+ "20": 0.0,
164
+ "21": 0.0,
165
+ "22": 0.0
166
+ },
167
+ "268": {
168
+ "17": 0.0,
169
+ "18": 0.0,
170
+ "19": 0.0,
171
+ "20": 0.0,
172
+ "21": 0.0,
173
+ "22": 0.0
174
+ },
175
+ "318": {
176
+ "17": 0.0,
177
+ "18": 0.0,
178
+ "19": 0.0,
179
+ "20": 0.0,
180
+ "21": 0.0,
181
+ "22": 0.0
182
+ },
183
+ "324": {
184
+ "17": 0.0,
185
+ "18": 0.0,
186
+ "19": 0.0,
187
+ "21": 0.0,
188
+ "22": 0.0
189
+ },
190
+ "727": {
191
+ "17": 0.0,
192
+ "18": 0.0,
193
+ "19": 0.0,
194
+ "20": 0.0,
195
+ "21": 0.0,
196
+ "22": 0.0
197
+ },
198
+ "381": {
199
+ "17": 0.0,
200
+ "18": 0.0,
201
+ "19": 0.0,
202
+ "20": 0.0,
203
+ "21": 0.0,
204
+ "22": 0.0
205
+ },
206
+ "402": {
207
+ "17": 0.0,
208
+ "18": 0.0,
209
+ "19": 0.0,
210
+ "20": 0.0,
211
+ "21": 0.0,
212
+ "3_vs_13": 34.0
213
+ },
214
+ "413": {
215
+ "17": 0.0,
216
+ "18": 0.0,
217
+ "19": 0.0,
218
+ "20": 0.0,
219
+ "21": 0.0,
220
+ "22": 0.0
221
+ },
222
+ "119": {
223
+ "17": 0.0,
224
+ "18": 0.0,
225
+ "19": 0.0,
226
+ "20": 0.0,
227
+ "21": 0.0
228
+ },
229
+ "438": {
230
+ "17": 0.0,
231
+ "18": 0.0,
232
+ "19": 0.0,
233
+ "20": 0.0,
234
+ "21": 0.0,
235
+ "22": 0.0
236
+ },
237
+ "452": {
238
+ "17": 0.0,
239
+ "18": 0.0,
240
+ "19": 0.0,
241
+ "20": 0.0,
242
+ "21": 0.0
243
+ },
244
+ "299": {
245
+ "17": 0.0,
246
+ "18": 0.0,
247
+ "19": 0.0,
248
+ "20": 0.0,
249
+ "21": 0.0,
250
+ "22": 0.0,
251
+ "32": 76.0
252
+ },
253
+ "302": {
254
+ "17": 0.0,
255
+ "18": 0.0,
256
+ "19": 0.0,
257
+ "20": 0.0,
258
+ "21": 0.0,
259
+ "22": 0.0
260
+ },
261
+ "521": {
262
+ "17": 0.0,
263
+ "18": 0.0,
264
+ "19": 0.0,
265
+ "20": 0.0,
266
+ "21": 0.0
267
+ },
268
+ "541": {
269
+ "17": 0.0,
270
+ "18": 0.0,
271
+ "19": 0.0,
272
+ "20": 0.0,
273
+ "21": 0.0,
274
+ "32": 83.0
275
+ },
276
+ "543": {
277
+ "17": 0.0,
278
+ "18": 0.0,
279
+ "19": 0.0,
280
+ "20": 0.0,
281
+ "21": 0.0,
282
+ "22": 0.0
283
+ },
284
+ "544": {
285
+ "17": 0.0,
286
+ "18": 0.0,
287
+ "19": 0.0,
288
+ "20": 0.0,
289
+ "21": 0.0,
290
+ "22": 0.0
291
+ },
292
+ "552": {
293
+ "17": 0.0,
294
+ "18": 0.0,
295
+ "19": 0.0,
296
+ "20": 0.0,
297
+ "21": 0.0
298
+ },
299
+ "553": {
300
+ "17": 0.0,
301
+ "18": 0.0,
302
+ "19": 0.0,
303
+ "20": 0.0,
304
+ "21": 0.0,
305
+ "22": 0.0
306
+ },
307
+ "678": {
308
+ "17": 0.0,
309
+ "18": 0.0,
310
+ "19": 0.0,
311
+ "20": 0.0,
312
+ "21": 0.0
313
+ },
314
+ "735": {
315
+ "17": 0.0,
316
+ "18": 0.0,
317
+ "19": 0.0,
318
+ "20": 0.0,
319
+ "21": 0.0
320
+ },
321
+ "603": {
322
+ "17": 0.0,
323
+ "18": 0.0,
324
+ "19": 0.0,
325
+ "20": 0.0,
326
+ "21": 0.0,
327
+ "22": 0.0
328
+ },
329
+ "631": {
330
+ "17": 0.0,
331
+ "18": 0.0,
332
+ "19": 0.0,
333
+ "20": 0.0,
334
+ "21": 0.0
335
+ },
336
+ "648": {
337
+ "17": 0.0,
338
+ "18": 0.0,
339
+ "19": 0.0,
340
+ "20": 0.0,
341
+ "21": 0.0
342
+ },
343
+ "695": {
344
+ "17": 0.0,
345
+ "18": 0.0,
346
+ "19": 0.0,
347
+ "20": 0.0,
348
+ "21": 0.0
349
+ },
350
+ "568": {
351
+ "6": 72.35
352
+ },
353
+ "407": {
354
+ "6": 63.56
355
+ },
356
+ "411": {
357
+ "24": 72.0,
358
+ "30": 42.0
359
+ },
360
+ "367": {
361
+ "7": 88.42,
362
+ "8": 88.42,
363
+ "9": 88.42,
364
+ "10": 88.42,
365
+ "11": 88.42,
366
+ "12": 88.0,
367
+ "32": 90.0,
368
+ "33": 90.0,
369
+ "34": 90.0,
370
+ "35": 90.0,
371
+ "36": 90.0,
372
+ "37": 9.0
373
+ },
374
+ "100": {
375
+ "9": 71.0,
376
+ "10": 64.0,
377
+ "32": 49.0,
378
+ "4_vs_11": 59.0
379
+ },
380
+ "252": {
381
+ "9": 48.0
382
+ },
383
+ "22": {
384
+ "11": 82.0,
385
+ "13": 83.0
386
+ },
387
+ "10": {
388
+ "16": 81.0
389
+ },
390
+ "570": {
391
+ "13": 84.0,
392
+ "24": 81.0,
393
+ "30": 88.0
394
+ },
395
+ "572": {
396
+ "30": 84.0
397
+ },
398
+ "567": {
399
+ "31": 90.0
400
+ },
401
+ "573": {
402
+ "30": 84.0
403
+ },
404
+ "725": {
405
+ "14": 86.0,
406
+ "15": 84.0,
407
+ "16": 85.0,
408
+ "17": 82.0,
409
+ "18": 67.0,
410
+ "19": 77.0,
411
+ "20": 80.0,
412
+ "21": 78.0,
413
+ "22": 75.0
414
+ },
415
+ "662": {
416
+ "14": 84.0
417
+ },
418
+ "721": {
419
+ "14": 86.0
420
+ },
421
+ "674": {
422
+ "14": 84.0,
423
+ "15": 84.0,
424
+ "17": 88.0,
425
+ "18": 85.0
426
+ },
427
+ "7": {
428
+ "16": 0.0
429
+ },
430
+ "263": {
431
+ "16": 87.0,
432
+ "17": 86.0,
433
+ "18": 84.0,
434
+ "19": 82.0,
435
+ "20": 67.0,
436
+ "21": 58.0,
437
+ "22": 65.0
438
+ },
439
+ "447": {
440
+ "16": 74.0,
441
+ "17": 68.0,
442
+ "18": 60.0
443
+ },
444
+ "11": {
445
+ "16": 83.0,
446
+ "32": 58.0
447
+ },
448
+ "30": {
449
+ "18": 0.0,
450
+ "32": 63.0
451
+ },
452
+ "295": {
453
+ "20": 47.0,
454
+ "21": 88.0,
455
+ "24": 85.0
456
+ },
457
+ "719": {
458
+ "20": 85.0,
459
+ "21": 83.0,
460
+ "22": 80.0
461
+ },
462
+ "321": {
463
+ "21": 87.0,
464
+ "22": 85.0
465
+ },
466
+ "319": {
467
+ "21": 77.0,
468
+ "22": 76.0
469
+ },
470
+ "405": {
471
+ "21": 76.0,
472
+ "22": 75.0
473
+ },
474
+ "406": {
475
+ "21": 78.0,
476
+ "22": 77.0,
477
+ "23": 76.0,
478
+ "24": 75.0,
479
+ "32": 68.0,
480
+ "13_vs_1": 69.0,
481
+ "3_vs_13": 21.0
482
+ },
483
+ "808": {
484
+ "24": 75.0
485
+ },
486
+ "113": {
487
+ "24": 80.0
488
+ },
489
+ "712": {
490
+ "24": 79.0
491
+ },
492
+ "273": {
493
+ "24": 0.0
494
+ },
495
+ "814": {
496
+ "25": 52.0
497
+ },
498
+ "400": {
499
+ "26": 90.0,
500
+ "27": 90.0,
501
+ "28": 90.0
502
+ },
503
+ "723": {
504
+ "26": 85.0,
505
+ "27": 85.0
506
+ },
507
+ "812": {
508
+ "29": 90.0,
509
+ "30": 90.0
510
+ },
511
+ "20": {
512
+ "32": 52.0
513
+ },
514
+ "48": {
515
+ "32": 26.0,
516
+ "33": 44.0
517
+ },
518
+ "143": {
519
+ "32": 71.0,
520
+ "35": 24.0,
521
+ "18_vs_6": 75.0,
522
+ "6_vs_7": 1.0
523
+ },
524
+ "146": {
525
+ "32": 0.0,
526
+ "18_vs_6": 7.0
527
+ },
528
+ "160": {
529
+ "32": 62.0,
530
+ "6_vs_7": 71.0
531
+ },
532
+ "163": {
533
+ "32": 5.0,
534
+ "18_vs_6": 4.0
535
+ },
536
+ "157": {
537
+ "32": 84.0,
538
+ "18_vs_6": 84.0,
539
+ "6_vs_7": 75.0
540
+ },
541
+ "173": {
542
+ "32": 82.0,
543
+ "6_vs_7": 82.0
544
+ },
545
+ "178": {
546
+ "32": 77.0,
547
+ "6_vs_7": 65.0,
548
+ "18_vs_6": 78.0
549
+ },
550
+ "85": {
551
+ "32": 0.0
552
+ },
553
+ "109": {
554
+ "32": 0.0,
555
+ "33": 0.0
556
+ },
557
+ "231": {
558
+ "32": 32.0
559
+ },
560
+ "232": {
561
+ "32": 67.0,
562
+ "6_vs_7": 47.0
563
+ },
564
+ "237": {
565
+ "32": 0.0,
566
+ "7_vs_14": 51.0
567
+ },
568
+ "672": {
569
+ "32": 68.0
570
+ },
571
+ "224": {
572
+ "32": 35.0,
573
+ "6_vs_7": 64.0,
574
+ "7_vs_14": 79.0
575
+ },
576
+ "342": {
577
+ "32": 74.0,
578
+ "4_vs_11": 47.0
579
+ },
580
+ "348": {
581
+ "32": 68.0
582
+ },
583
+ "356": {
584
+ "32": 73.0
585
+ },
586
+ "660": {
587
+ "32": 0.0,
588
+ "11_vs_20": 0.0,
589
+ "4_vs_11": 0.0,
590
+ "35": 70.0
591
+ },
592
+ "366": {
593
+ "32": 0.0,
594
+ "33": 0.0,
595
+ "34": 0.0,
596
+ "35": 0.0,
597
+ "36": 0.0,
598
+ "37": 81.0
599
+ },
600
+ "442": {
601
+ "32": 0.0
602
+ },
603
+ "475": {
604
+ "32": 0.0,
605
+ "33": 0.0,
606
+ "34": 0.0,
607
+ "35": 0.0,
608
+ "36": 0.0,
609
+ "37": 20.0
610
+ },
611
+ "488": {
612
+ "32": 0.0
613
+ },
614
+ "497": {
615
+ "32": 0.0
616
+ },
617
+ "531": {
618
+ "32": 53.0
619
+ },
620
+ "326": {
621
+ "32": 70.0
622
+ },
623
+ "609": {
624
+ "32": 0.0
625
+ },
626
+ "615": {
627
+ "32": 60.0,
628
+ "33": 71.0,
629
+ "34": 75.0
630
+ },
631
+ "606": {
632
+ "32": 26.0
633
+ },
634
+ "791": {
635
+ "32": 80.0
636
+ },
637
+ "21": {
638
+ "32": 84.0
639
+ },
640
+ "8": {
641
+ "32": 54.0
642
+ },
643
+ "152": {
644
+ "35": 30.0
645
+ },
646
+ "169": {
647
+ "32": 72.0,
648
+ "18_vs_6": 10.0
649
+ },
650
+ "110": {
651
+ "32": 81.0
652
+ },
653
+ "116": {
654
+ "32": 63.0
655
+ },
656
+ "220": {
657
+ "36": 82.0,
658
+ "37": 80.0,
659
+ "38": 78.0
660
+ },
661
+ "228": {
662
+ "32": 71.0,
663
+ "7_vs_14": 75.0,
664
+ "6_vs_7": 68.0
665
+ },
666
+ "230": {
667
+ "32": 78.0,
668
+ "7_vs_14": 77.0,
669
+ "6_vs_7": 69.0
670
+ },
671
+ "347": {
672
+ "32": 78.0,
673
+ "11_vs_20": 88.0,
674
+ "4_vs_11": 88.0
675
+ },
676
+ "350": {
677
+ "32": 77.0,
678
+ "11_vs_20": 75.0,
679
+ "4_vs_11": 73.0
680
+ },
681
+ "370": {
682
+ "32": 47.0
683
+ },
684
+ "408": {
685
+ "32": 0.0,
686
+ "13_vs_1": 3.0,
687
+ "3_vs_13": 73.0
688
+ },
689
+ "441": {
690
+ "32": 10.0
691
+ },
692
+ "716": {
693
+ "33": 0.0
694
+ },
695
+ "694": {
696
+ "32": 77.0
697
+ },
698
+ "813": {
699
+ "32": 0.0
700
+ },
701
+ "417": {
702
+ "13_vs_1": 72.0,
703
+ "3_vs_13": 73.0
704
+ },
705
+ "666": {
706
+ "32": 63.0
707
+ },
708
+ "148": {
709
+ "18_vs_6": 86.0,
710
+ "6_vs_7": 86.0
711
+ },
712
+ "151": {
713
+ "18_vs_6": 87.0,
714
+ "6_vs_7": 87.0
715
+ },
716
+ "158": {
717
+ "6_vs_7": 71.0
718
+ },
719
+ "783": {
720
+ "6_vs_7": 85.0
721
+ },
722
+ "90": {
723
+ "4_vs_11": 70.0,
724
+ "15_vs_4": 72.0
725
+ },
726
+ "97": {
727
+ "4_vs_11": 66.0,
728
+ "15_vs_4": 79.0
729
+ },
730
+ "200": {
731
+ "16_vs_3": 67.0,
732
+ "3_vs_13": 61.0
733
+ },
734
+ "215": {
735
+ "3_vs_13": 42.0
736
+ },
737
+ "225": {
738
+ "7_vs_14": 2.0
739
+ },
740
+ "226": {
741
+ "35": 0.0
742
+ },
743
+ "236": {
744
+ "6_vs_7": 47.0
745
+ },
746
+ "453": {
747
+ "6_vs_7": 76.0
748
+ },
749
+ "365": {
750
+ "11_vs_20": 18.0,
751
+ "4_vs_11": 27.0
752
+ }
753
+ }
auth.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from sqlalchemy.orm import Session
3
+ from pydantic import BaseModel
4
+ import bcrypt
5
+ from jose import jwt
6
+ from datetime import datetime, timedelta
7
+ from google.oauth2 import id_token
8
+ from google.auth.transport import requests
9
+ from sqlalchemy.orm.attributes import flag_modified
10
+ from database import User, get_db
11
+ from fastapi.security import OAuth2PasswordBearer
12
+
13
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
14
+
15
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
16
+
17
+ # --- SECURITY CONFIG ---
18
+ SECRET_KEY = "super_secret_luigi_key_change_this_later_in_production"
19
+ ALGORITHM = "HS256"
20
+ # You will get this ID from Google Cloud Console later
21
+ GOOGLE_CLIENT_ID = (
22
+ "525088967752-vhdm44u6qddh5ldot4p1hibe1k0f7mk2.apps.googleusercontent.com"
23
+ )
24
+
25
+
26
+ # --- PYDANTIC MODELS (Payloads) ---
27
+ class UserCreate(BaseModel):
28
+ email: str
29
+ password: str
30
+
31
+
32
+ class UserLogin(BaseModel):
33
+ email: str
34
+ password: str
35
+
36
+
37
+ class GoogleLogin(BaseModel):
38
+ token: str
39
+
40
+
41
+ # --- HELPER FUNCTIONS ---
42
+ def create_access_token(data: dict):
43
+ to_encode = data.copy()
44
+ expire = datetime.utcnow() + timedelta(days=7) # Stay logged in for 7 days
45
+ to_encode.update({"exp": expire})
46
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
47
+
48
+
49
+ def check_admin_status(email: str):
50
+ """The magic function that makes you God"""
51
+ if email.lower() == "anayshukla11@gmail.com":
52
+ return True
53
+ return False
54
+
55
+
56
+ # --- ROUTES ---
57
+
58
+
59
+ @router.post("/register")
60
+ def register_user(user: UserCreate, db: Session = Depends(get_db)):
61
+ # 1. Check if email exists
62
+ db_user = db.query(User).filter(User.email == user.email).first()
63
+ if db_user:
64
+ raise HTTPException(status_code=400, detail="Email already registered")
65
+
66
+ # 2. Hash password directly with bcrypt
67
+ salt = bcrypt.gensalt()
68
+ hashed_pw = bcrypt.hashpw(user.password.encode("utf-8"), salt).decode("utf-8")
69
+
70
+ # 3. Check if it is the admin email
71
+ is_admin = check_admin_status(user.email)
72
+
73
+ # 4. Save to DB
74
+ new_user = User(email=user.email, hashed_password=hashed_pw, is_admin=is_admin)
75
+ db.add(new_user)
76
+ db.commit()
77
+ db.refresh(new_user)
78
+
79
+ # 5. Issue Token
80
+ token = create_access_token(
81
+ {"sub": new_user.email, "role": "admin" if is_admin else "user"}
82
+ )
83
+ return {
84
+ "access_token": token,
85
+ "token_type": "bearer",
86
+ "email": new_user.email,
87
+ "is_admin": is_admin,
88
+ }
89
+
90
+
91
+ @router.post("/login")
92
+ def login_user(user: UserLogin, db: Session = Depends(get_db)):
93
+ # 1. Fetch user
94
+ db_user = db.query(User).filter(User.email == user.email).first()
95
+ if not db_user or not db_user.hashed_password:
96
+ raise HTTPException(status_code=401, detail="Invalid credentials")
97
+
98
+ # 2. Verify password directly with bcrypt
99
+ if not bcrypt.checkpw(
100
+ user.password.encode("utf-8"), db_user.hashed_password.encode("utf-8")
101
+ ):
102
+ raise HTTPException(status_code=401, detail="Invalid credentials")
103
+
104
+ # 3. Issue Token
105
+ token = create_access_token(
106
+ {"sub": db_user.email, "role": "admin" if db_user.is_admin else "user"}
107
+ )
108
+ return {
109
+ "access_token": token,
110
+ "token_type": "bearer",
111
+ "email": db_user.email,
112
+ "is_admin": db_user.is_admin,
113
+ }
114
+
115
+
116
+ @router.post("/google")
117
+ def google_auth(payload: GoogleLogin, db: Session = Depends(get_db)):
118
+ try:
119
+ # Verify the token Google's frontend sent us
120
+ # THE FIX: Added clock_skew_in_seconds=10 to forgive slight time differences!
121
+ idinfo = id_token.verify_oauth2_token(
122
+ payload.token,
123
+ requests.Request(),
124
+ GOOGLE_CLIENT_ID,
125
+ clock_skew_in_seconds=10,
126
+ )
127
+ email = idinfo["email"]
128
+
129
+ # Check if user exists
130
+ db_user = db.query(User).filter(User.email == email).first()
131
+
132
+ # If they don't exist, register them silently via Google
133
+ if not db_user:
134
+ is_admin = check_admin_status(email)
135
+ db_user = User(
136
+ email=email, is_admin=is_admin
137
+ ) # No password needed for Google auth
138
+ db.add(db_user)
139
+ db.commit()
140
+ db.refresh(db_user)
141
+
142
+ token = create_access_token(
143
+ {"sub": db_user.email, "role": "admin" if db_user.is_admin else "user"}
144
+ )
145
+ return {
146
+ "access_token": token,
147
+ "token_type": "bearer",
148
+ "email": db_user.email,
149
+ "is_admin": db_user.is_admin,
150
+ }
151
+
152
+ except ValueError as e:
153
+ # THIS WILL TELL YOU EXACTLY WHY IT FAILED
154
+ print(f"GOOGLE AUTH ERROR: {str(e)}")
155
+ raise HTTPException(status_code=401, detail=f"Google Error: {str(e)}")
156
+
157
+
158
+ def get_current_user(
159
+ token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
160
+ ):
161
+ try:
162
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
163
+ email = payload.get("sub")
164
+ if email is None:
165
+ raise HTTPException(status_code=401)
166
+ except:
167
+ raise HTTPException(status_code=401)
168
+
169
+ user = db.query(User).filter(User.email == email).first()
170
+ if user is None:
171
+ raise HTTPException(status_code=401)
172
+ return user
173
+
174
+
175
+ @router.get("/me")
176
+ def get_user_me(current_user: User = Depends(get_current_user)):
177
+ return {
178
+ "email": current_user.email,
179
+ "is_admin": current_user.is_admin,
180
+ "default_team_id": current_user.default_team_id,
181
+ "saved_edits": current_user.saved_edits,
182
+ "drafts": current_user.drafts, # <-- NEW: Send realities to React
183
+ }
184
+
185
+
186
+ @router.post("/save_session")
187
+ def save_session(
188
+ payload: dict,
189
+ current_user: User = Depends(get_current_user),
190
+ db: Session = Depends(get_db),
191
+ ):
192
+ if "default_team_id" in payload:
193
+ current_user.default_team_id = payload["default_team_id"]
194
+
195
+ if "saved_edits" in payload:
196
+ current_user.saved_edits = payload["saved_edits"]
197
+ flag_modified(current_user, "saved_edits")
198
+
199
+ # THE FIX: Catch and permanently save the Multiverse array
200
+ if "drafts" in payload:
201
+ current_user.drafts = payload["drafts"]
202
+ flag_modified(current_user, "drafts")
203
+
204
+ db.commit()
205
+ return {"status": "success"}
database.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine, Column, Integer, String, Boolean, JSON
3
+ from sqlalchemy.orm import sessionmaker, declarative_base
4
+
5
+ # Paste your Supabase URI here. Replace [YOUR-PASSWORD] with your actual password!
6
+ SUPABASE_URL = "postgresql://postgres.gjbfbkhygtqubvpbquws:Anayshukla11$$@aws-1-ap-south-1.pooler.supabase.com:6543/postgres"
7
+
8
+ # SQLAlchemy requires the URL to start with 'postgresql://'
9
+ if SUPABASE_URL.startswith("postgres://"):
10
+ SUPABASE_URL = SUPABASE_URL.replace("postgres://", "postgresql://", 1)
11
+
12
+ engine = create_engine(SUPABASE_URL, pool_pre_ping=True)
13
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
14
+ Base = declarative_base()
15
+
16
+
17
+ # --- THE USER DATABASE MODEL ---
18
+ class User(Base):
19
+ __tablename__ = "users"
20
+
21
+ id = Column(Integer, primary_key=True, index=True)
22
+ email = Column(String, unique=True, index=True)
23
+ hashed_password = Column(String, nullable=True)
24
+ is_admin = Column(Boolean, default=False)
25
+
26
+ # User FPL State
27
+ default_team_id = Column(Integer, nullable=True)
28
+ saved_edits = Column(JSON, default={})
29
+ drafts = Column(JSON, default=[])
30
+ solver_settings = Column(JSON, default={"quick": {}, "advanced": {}})
31
+
32
+
33
+ # --- THE NEW JSON VAULT ---
34
+ class GlobalConfig(Base):
35
+ __tablename__ = "global_config"
36
+ key = Column(String, primary_key=True, index=True)
37
+ value = Column(JSON, default={})
38
+
39
+
40
+ # Create the tables in the Supabase database
41
+ Base.metadata.create_all(bind=engine)
42
+
43
+
44
+ # Dependency to get the DB session in our API routes
45
+ def get_db():
46
+ db = SessionLocal()
47
+ try:
48
+ yield db
49
+ finally:
50
+ db.close()
engine.py ADDED
@@ -0,0 +1,613 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd
2
+ import numpy as np
3
+ import math
4
+ from scipy.stats import nbinom
5
+
6
+
7
+ def poisson_probability_of_conceding_2_or_more_goals(lambd):
8
+ """Calculates the probability of conceding 2 or more goals using Poisson distribution."""
9
+ p_0 = math.exp(-lambd)
10
+ p_1 = lambd * math.exp(-lambd)
11
+ return 1 - p_0 - p_1
12
+
13
+
14
+ def poisson_pmf(k, lambd):
15
+ """Calculates the Poisson Probability Mass Function P(X=k)."""
16
+ if k < 0:
17
+ return 0.0
18
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
19
+ return 1.0 if k == 0 else 0.0
20
+ return (lambd**k * math.exp(-lambd)) / math.factorial(k)
21
+
22
+
23
+ def neg_binom_probability_of_value(expected_mean, value, dispersion=1.0):
24
+ """
25
+ Calculates the exact probability (PMF) of getting exactly 'value' events.
26
+ Used for: Saves, Goals, Assists.
27
+ """
28
+ if expected_mean <= 0:
29
+ return 0.0
30
+ if dispersion <= 1.0: # Fallback to Poisson if no dispersion
31
+ return poisson_pmf(value, expected_mean)
32
+
33
+ # Convert Mean + Dispersion to n, p
34
+ p = 1 / dispersion
35
+ n = (expected_mean * p) / (1 - p)
36
+
37
+ return nbinom.pmf(value, n, p)
38
+
39
+
40
+ def neg_binom_probability_at_least(expected_mean, threshold, dispersion=1.0):
41
+ """
42
+ Calculates probability of getting 'threshold' OR MORE events.
43
+ Used for: DefCons (CBIT), Recoveries.
44
+ """
45
+ if expected_mean <= 0:
46
+ return 0.0
47
+ if dispersion <= 1.0:
48
+ # Use existing Poisson logic if dispersion is low
49
+ return 1 - poisson_cdf(threshold - 1, expected_mean)
50
+
51
+ p = 1 / dispersion
52
+ n = (expected_mean * p) / (1 - p)
53
+
54
+ # Probability of X >= threshold is (1 - CDF(threshold - 1))
55
+ return 1 - nbinom.cdf(threshold - 1, n, p)
56
+
57
+
58
+ def calculate_expected_conceded_points(lambd):
59
+ """
60
+ Calculates the expected fantasy points from goals conceded based on a
61
+ -1 point penalty for every 2 goals.
62
+ """
63
+ total_expected_points = 0
64
+ max_goals_to_check = 10
65
+
66
+ for k in range(max_goals_to_check + 1):
67
+ prob_k = poisson_pmf(k=k, lambd=lambd)
68
+ points_for_k_goals = -(k // 2)
69
+ total_expected_points += prob_k * points_for_k_goals
70
+
71
+ return total_expected_points
72
+
73
+
74
+ def poisson_cdf(k, lambd):
75
+ """Calculates the Poisson Cumulative Distribution Function P(X<=k)."""
76
+ if k < 0:
77
+ return 0.0
78
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
79
+ return 1.0 if k >= 0 else 0.0
80
+ return sum(poisson_pmf(i, lambd) for i in range(math.floor(k) + 1))
81
+
82
+
83
+ def apply_team_skepticism(df, skepticism_factors):
84
+ """
85
+ Applies a skepticism multiplier to a player's base points based on their team.
86
+ """
87
+ if not skepticism_factors:
88
+ return df
89
+
90
+ for team_id, multiplier in skepticism_factors.items():
91
+ players_on_team = df[df["team"] == team_id].index
92
+ df.loc[players_on_team, "base_pts"] *= multiplier
93
+
94
+ return df
95
+
96
+
97
+ def calculate_single_match_points(
98
+ player,
99
+ match_row,
100
+ xMins_in_match,
101
+ points_config,
102
+ player_penalty_shares,
103
+ is_gk=False,
104
+ is_def=False,
105
+ is_mid=False,
106
+ is_fwd=False,
107
+ ):
108
+ """
109
+ Calculates points for a single match given the xMins and match projections.
110
+ Includes full logic for CBIT, CBITR, Penalty Saves, and dynamic BPS.
111
+ """
112
+ if xMins_in_match <= 0:
113
+ return {"pts": 0.0, "xG": 0.0, "xA": 0.0, "CS": 0.0, "cbit": 0.0, "cbitr": 0.0}
114
+
115
+ scaling_factor = xMins_in_match / 90.0
116
+ player_team_num = player["team"]
117
+ player_pos = player["element_type"]
118
+
119
+ # 1. Identify Home/Away and get Opponent Stats
120
+ if player_team_num == match_row["home_team_num"]:
121
+ team_proj_goals = match_row["mc_home_goals_mean"]
122
+ team_conc_goals = match_row["mc_away_goals_mean"]
123
+ team_proj_assists = match_row["mc_home_assists_xa_mean"]
124
+ team_proj_cbit = match_row["mc_home_CBIT_mean"]
125
+ team_proj_cbitr = match_row["mc_home_CBITR_mean"]
126
+ team_proj_saves = match_row["mc_home_keeper_saves_mean"]
127
+ team_proj_yc = match_row["mc_home_yc_mean"]
128
+ team_proj_rc = match_row["mc_home_rc_mean"]
129
+ cs_odds = match_row["home_clean_sheet_odds"]
130
+ else:
131
+ team_proj_goals = match_row["mc_away_goals_mean"]
132
+ team_conc_goals = match_row["mc_home_goals_mean"]
133
+ team_proj_assists = match_row["mc_away_assists_xa_mean"]
134
+ team_proj_cbit = match_row["mc_away_CBIT_mean"]
135
+ team_proj_cbitr = match_row["mc_away_CBITR_mean"]
136
+ team_proj_saves = match_row["mc_away_keeper_saves_mean"]
137
+ team_proj_yc = match_row["mc_away_yc_mean"]
138
+ team_proj_rc = match_row["mc_away_rc_mean"]
139
+ cs_odds = match_row["away_clean_sheet_odds"]
140
+
141
+ # 2. Player Share Calculations
142
+ proj_goals = player["xG_share"] * team_proj_goals
143
+ proj_assists = player["xA_share"] * team_proj_assists
144
+ proj_cbit = player["xCBIT_share"] * team_proj_cbit
145
+ proj_cbitr = player["xCBITR_share"] * team_proj_cbitr
146
+
147
+ proj_saves = 0
148
+ proj_pen_saves = 0
149
+ if is_gk:
150
+ proj_saves = (player["baseline_xSaves_p90"] + team_proj_saves) / 2
151
+ proj_pen_saves = player["baseline_pksave_p90"]
152
+
153
+ # --- GOALS & ASSISTS ---
154
+ pts_goals = (
155
+ sum(
156
+ poisson_pmf(k, proj_goals) * k * points_config["goal"][player_pos]
157
+ for k in range(9)
158
+ )
159
+ * scaling_factor
160
+ )
161
+ pts_assists = (
162
+ sum(
163
+ poisson_pmf(k, proj_assists) * k * points_config["assist"] for k in range(9)
164
+ )
165
+ * scaling_factor
166
+ )
167
+
168
+ # --- CLEAN SHEET & CONCEDED ---
169
+ pts_cs = (
170
+ cs_odds * points_config["clean_sheet"][player_pos]
171
+ if xMins_in_match >= 60
172
+ else (cs_odds * points_config["clean_sheet"][player_pos]) * scaling_factor
173
+ )
174
+ pts_conc = (
175
+ calculate_expected_conceded_points(team_conc_goals) * scaling_factor
176
+ if (is_gk or is_def) and team_conc_goals is not None
177
+ else 0.0
178
+ )
179
+
180
+ # --- CARDS ---
181
+ pts_yc = (player["YC_share"] * team_proj_yc * -1) * scaling_factor
182
+ pts_rc = (player["RC_share"] * team_proj_rc * -3) * scaling_factor
183
+
184
+ # --- SAVES & PENALTY SAVES (GK) ---
185
+ pts_saves = 0.0
186
+ pts_pen_save = 0.0
187
+ if is_gk:
188
+ expected_saves_pts_unscaled = sum(
189
+ neg_binom_probability_of_value(proj_saves, k, dispersion=1.5)
190
+ * ((k // 3) * points_config["saves_per_3"])
191
+ for k in range(21)
192
+ )
193
+ pts_saves = expected_saves_pts_unscaled * scaling_factor
194
+ expected_pen_saved_pts_unscaled = sum(
195
+ poisson_pmf(k, proj_pen_saves) * (k * 5) for k in range(3)
196
+ )
197
+ pts_pen_save = expected_pen_saved_pts_unscaled * scaling_factor
198
+
199
+ # --- CBIT & CBITR ---
200
+ pts_cbit = (
201
+ (
202
+ neg_binom_probability_at_least(proj_cbit, 10, dispersion=3.2)
203
+ * 2
204
+ * scaling_factor
205
+ )
206
+ if is_def
207
+ else 0.0
208
+ )
209
+ pts_cbitr = 0.0
210
+ if is_mid:
211
+ pts_cbitr = (
212
+ neg_binom_probability_at_least(proj_cbitr, 12, dispersion=2.8)
213
+ * 2
214
+ * scaling_factor
215
+ )
216
+ elif is_fwd:
217
+ pts_cbitr = (
218
+ neg_binom_probability_at_least(proj_cbitr, 12, dispersion=1.7)
219
+ * 2
220
+ * scaling_factor
221
+ )
222
+
223
+ # --- PENALTY POINTS (Taker) ---
224
+ pts_penalty = 0.0
225
+ if player_penalty_shares and player["id"] in player_penalty_shares:
226
+ pen_share = player_penalty_shares[player["id"]]
227
+ base_pen_pts = points_config["penalty_points_per_position"].get(player_pos, 0)
228
+ pts_penalty = (base_pen_pts * pen_share) * scaling_factor
229
+
230
+ # --- APPEARANCE ---
231
+ pts_app = 2 if xMins_in_match > 60 else (1 if xMins_in_match > 0 else 0)
232
+
233
+ # --- BONUS POINTS ---
234
+ bps_floor = player["baseline_bps_floor_p90"] * scaling_factor
235
+ bps_mins = 6 if xMins_in_match >= 60 else (3 if xMins_in_match > 0 else 0)
236
+
237
+ scaled_goals = proj_goals * scaling_factor
238
+ scaled_assists = proj_assists * scaling_factor
239
+ scaled_saves = proj_saves * scaling_factor if is_gk else 0
240
+ scaled_pen_saves = proj_pen_saves * scaling_factor if is_gk else 0
241
+ scaled_yc = player["YC_share"] * team_proj_yc * scaling_factor
242
+ scaled_rc = player["RC_share"] * team_proj_rc * scaling_factor
243
+
244
+ bps_goals = scaled_goals * (24 if is_fwd else (18 if is_mid else 12))
245
+ bps_assists = scaled_assists * 9
246
+ bps_cs = cs_odds * 12 if (is_gk or is_def) and xMins_in_match >= 60 else 0
247
+ bps_saves = scaled_saves * 2
248
+ bps_pen_saves = scaled_pen_saves * 15
249
+ bps_cards = (scaled_yc * -3) + (scaled_rc * -9)
250
+
251
+ total_projected_bps = (
252
+ bps_floor
253
+ + bps_mins
254
+ + bps_goals
255
+ + bps_assists
256
+ + bps_cs
257
+ + bps_saves
258
+ + bps_pen_saves
259
+ + bps_cards
260
+ )
261
+ pts_bonus = total_projected_bps / 29.4 if not is_gk else 0.0
262
+
263
+ # --- FINAL SUM ---
264
+ total_pts = (
265
+ pts_goals
266
+ + pts_assists
267
+ + pts_cs
268
+ + pts_conc
269
+ + pts_yc
270
+ + pts_rc
271
+ + pts_saves
272
+ + pts_pen_save
273
+ + pts_cbit
274
+ + pts_cbitr
275
+ + pts_penalty
276
+ + pts_app
277
+ + pts_bonus
278
+ )
279
+
280
+ return {
281
+ "pts": total_pts,
282
+ "xG": proj_goals * scaling_factor,
283
+ "xA": proj_assists * scaling_factor,
284
+ "CS": cs_odds if xMins_in_match >= 60 else cs_odds * scaling_factor,
285
+ "cbit": proj_cbit * scaling_factor,
286
+ "cbitr": proj_cbitr * scaling_factor,
287
+ }
288
+
289
+
290
+ def calculate_all_points(
291
+ player_df_base,
292
+ match_df,
293
+ player_penalty_shares,
294
+ MINS_SCALING_BONUS,
295
+ pos_map,
296
+ teams_dict_1,
297
+ teams_dict,
298
+ points_config,
299
+ effective_xmins_overrides,
300
+ MINS_THRESHOLD,
301
+ RAMP_UP_PERIOD,
302
+ decay_rates,
303
+ ramp_up_rates,
304
+ user_player_status_overrides,
305
+ team_skepticism,
306
+ effective_availability_multipliers,
307
+ ):
308
+ RAMP_UP_PERIOD = 3
309
+ player_df = player_df_base.copy()
310
+
311
+ final_df_output = pd.DataFrame(
312
+ {
313
+ "Pos": player_df["element_type"].map(pos_map),
314
+ "ID": player_df["id"],
315
+ "Name": player_df["web_name"],
316
+ "BV": player_df["now_cost"],
317
+ "SV": player_df["now_cost"],
318
+ "Team": player_df["Team"],
319
+ }
320
+ )
321
+
322
+ continuous_xMins_progression = player_df["baseline_xMins"].copy()
323
+ has_baseline_xmins_override = getattr(player_df, "attrs", {}).get(
324
+ "has_baseline_xmins_override", False
325
+ )
326
+ all_baseline_overrides = getattr(player_df, "attrs", {}).get(
327
+ "all_baseline_overrides", {}
328
+ )
329
+ unique_gws = sorted(match_df["GW"].unique())
330
+
331
+ match_projections_col = {index: {} for index in player_df.index}
332
+
333
+ for gw_idx, gw in enumerate(unique_gws):
334
+ if has_baseline_xmins_override and gw == 1:
335
+ for index, player in player_df.iterrows():
336
+ player_id = player["id"]
337
+ if (
338
+ player_id in all_baseline_overrides
339
+ and "baseline_xMins" in all_baseline_overrides[player_id]
340
+ ):
341
+ continuous_xMins_progression.loc[index] = all_baseline_overrides[
342
+ player_id
343
+ ]["baseline_xMins"]
344
+
345
+ gw_calc_df = pd.DataFrame(index=player_df.index)
346
+ gw_calc_df["team"] = player_df["team"]
347
+ gw_calc_df["id"] = player_df["id"]
348
+ gw_calc_df["web_name"] = player_df["web_name"]
349
+ gw_calc_df["player_name"] = player_df["name"]
350
+ gw_calc_df["xG_share"] = player_df["xG_share"]
351
+ gw_calc_df["xA_share"] = player_df["xA_share"]
352
+ gw_calc_df["baseline_xMins"] = player_df["baseline_xMins"]
353
+ gw_calc_df["baseline_bps_floor_p90"] = player_df["baseline_bps_floor_p90"]
354
+ gw_calc_df["base_pts"] = 0.0
355
+
356
+ # VECTORIZED XMINS CALCULATION
357
+ player_ids_array = player_df["id"].values
358
+ n_players = len(player_ids_array)
359
+
360
+ status_list = [
361
+ user_player_status_overrides.get(pid, {"status": "default"})["status"]
362
+ for pid in player_ids_array
363
+ ]
364
+ weeks_out_list = [
365
+ user_player_status_overrides.get(pid, {}).get("weeks_out", 0)
366
+ for pid in player_ids_array
367
+ ]
368
+
369
+ status_array = np.array(status_list, dtype=object)
370
+ weeks_out_array = np.array(weeks_out_list)
371
+
372
+ is_not_starter = status_array == "not_a_starter"
373
+ is_suspended = status_array == "suspended"
374
+ is_injured = status_array == "injured"
375
+ is_default = ~(is_not_starter | is_suspended | is_injured)
376
+
377
+ baseline_mins_array = player_df["baseline_xMins"].values
378
+ prev_continuous_xmins_array = continuous_xMins_progression.values
379
+
380
+ calculated_xmins_array = np.zeros(n_players, dtype=float)
381
+ next_continuous_xmins_array = np.zeros(n_players, dtype=float)
382
+
383
+ first_gw = min(unique_gws)
384
+ is_first_gw = gw == first_gw
385
+ is_available_first_gw = ~(is_not_starter | is_suspended | is_injured)
386
+
387
+ # CASE 1: First GW + Available
388
+ if is_first_gw:
389
+ mask_first_available = is_available_first_gw
390
+ calculated_xmins_array[mask_first_available] = baseline_mins_array[
391
+ mask_first_available
392
+ ]
393
+
394
+ calculated_xmins_array[is_not_starter] = 0
395
+
396
+ # CASE 3: Suspended
397
+ mask_suspended_during = is_suspended & (gw <= weeks_out_array)
398
+ mask_suspended_return = is_suspended & (gw == weeks_out_array + 1)
399
+ mask_suspended_after = is_suspended & (gw > weeks_out_array + 1)
400
+
401
+ calculated_xmins_array[mask_suspended_during] = 0
402
+ calculated_xmins_array[mask_suspended_return] = baseline_mins_array[
403
+ mask_suspended_return
404
+ ]
405
+
406
+ decay_rate_susp = decay_rates.get("suspended", decay_rates.get("default", 0.99))
407
+ ramp_rate_susp = ramp_up_rates.get("suspended", ramp_up_rates.get("default", 0))
408
+
409
+ mask_susp_decay = mask_suspended_after & (
410
+ prev_continuous_xmins_array >= MINS_THRESHOLD
411
+ )
412
+ mask_susp_ramp = mask_suspended_after & (
413
+ prev_continuous_xmins_array < MINS_THRESHOLD
414
+ )
415
+
416
+ calculated_xmins_array[mask_susp_decay] = (
417
+ prev_continuous_xmins_array[mask_susp_decay] * decay_rate_susp
418
+ )
419
+ calculated_xmins_array[mask_susp_ramp] = np.minimum(
420
+ prev_continuous_xmins_array[mask_susp_ramp] + ramp_rate_susp, 90
421
+ )
422
+
423
+ # CASE 4: Injured
424
+ mask_injured_out = is_injured & (gw <= weeks_out_array)
425
+ calculated_xmins_array[mask_injured_out] = 0
426
+
427
+ mask_injured_recovering = is_injured & (gw > weeks_out_array)
428
+ weeks_since_injury_array = np.maximum(0, gw - weeks_out_array)
429
+
430
+ mask_ramp_phase = mask_injured_recovering & (
431
+ weeks_since_injury_array <= RAMP_UP_PERIOD
432
+ )
433
+ calculated_xmins_array[mask_ramp_phase] = (
434
+ baseline_mins_array[mask_ramp_phase] / RAMP_UP_PERIOD
435
+ ) * weeks_since_injury_array[mask_ramp_phase]
436
+
437
+ mask_post_ramp = mask_injured_recovering & (
438
+ weeks_since_injury_array > RAMP_UP_PERIOD
439
+ )
440
+
441
+ decay_rate_default = decay_rates.get("default", 0.99)
442
+ ramp_rate_default = ramp_up_rates.get(
443
+ "default", ramp_up_rates.get("injured", 0)
444
+ )
445
+
446
+ mask_post_decay = mask_post_ramp & (
447
+ prev_continuous_xmins_array >= MINS_THRESHOLD
448
+ )
449
+ mask_post_ramp_up = mask_post_ramp & (
450
+ prev_continuous_xmins_array < MINS_THRESHOLD
451
+ )
452
+
453
+ calculated_xmins_array[mask_post_decay] = (
454
+ prev_continuous_xmins_array[mask_post_decay] * decay_rate_default
455
+ )
456
+ calculated_xmins_array[mask_post_ramp_up] = np.minimum(
457
+ prev_continuous_xmins_array[mask_post_ramp_up] + ramp_rate_default, 90
458
+ )
459
+
460
+ # CASE 5: Default/healthy
461
+ mask_default_calc = is_default & ~(is_first_gw & is_available_first_gw)
462
+ element_type_array = player_df["element_type"].values
463
+ is_gk = element_type_array == 1
464
+
465
+ mask_gk_default = mask_default_calc & is_gk
466
+ calculated_xmins_array[mask_gk_default] = prev_continuous_xmins_array[
467
+ mask_gk_default
468
+ ]
469
+
470
+ mask_outfield_default = mask_default_calc & (~is_gk)
471
+ mask_outf_decay = mask_outfield_default & (
472
+ prev_continuous_xmins_array >= MINS_THRESHOLD
473
+ )
474
+ calculated_xmins_array[mask_outf_decay] = (
475
+ prev_continuous_xmins_array[mask_outf_decay] * decay_rate_default
476
+ )
477
+
478
+ mask_outf_ramp = (
479
+ mask_outfield_default
480
+ & (prev_continuous_xmins_array < MINS_THRESHOLD)
481
+ & (baseline_mins_array > 0)
482
+ )
483
+ calculated_xmins_array[mask_outf_ramp] = np.minimum(
484
+ prev_continuous_xmins_array[mask_outf_ramp] + ramp_rate_default, 90
485
+ )
486
+
487
+ calculated_xmins_array = np.clip(calculated_xmins_array, 0, 90)
488
+ next_continuous_xmins_array = calculated_xmins_array.copy()
489
+
490
+ # APPLY OVERRIDES AND AVAILABILITY
491
+ xMins_for_current_gw_display = calculated_xmins_array.copy()
492
+ for idx in range(n_players):
493
+ player_id = player_ids_array[idx]
494
+ availability_mult = effective_availability_multipliers.get(
495
+ player_id, {}
496
+ ).get(gw, 1.0)
497
+ xMins_for_current_gw_display[idx] *= availability_mult
498
+
499
+ if (
500
+ player_id in effective_xmins_overrides
501
+ and gw in effective_xmins_overrides[player_id]
502
+ ):
503
+ xMins_for_current_gw_display[idx] = effective_xmins_overrides[
504
+ player_id
505
+ ][gw]
506
+
507
+ xMins_for_current_gw_display = pd.Series(
508
+ xMins_for_current_gw_display, index=player_df.index
509
+ )
510
+ next_gw_continuous_xMins = pd.Series(
511
+ next_continuous_xmins_array, index=player_df.index
512
+ )
513
+ gw_calc_df[f"{gw}_xMins"] = xMins_for_current_gw_display
514
+
515
+ # STREAMLINED MATCH SCORING LOOP
516
+ gw_matches = match_df[match_df["GW"] == gw]
517
+
518
+ for index, player in player_df.iterrows():
519
+ player_team_num = player["team"]
520
+ my_matches = gw_matches[
521
+ (gw_matches["home_team_num"] == player_team_num)
522
+ | (gw_matches["away_team_num"] == player_team_num)
523
+ ]
524
+
525
+ if my_matches.empty:
526
+ gw_calc_df.loc[index, "base_pts"] = 0
527
+ gw_calc_df.loc[index, f"{gw}_xMins"] = 0
528
+ gw_calc_df.loc[index, "gw_xG"] = 0.0
529
+ gw_calc_df.loc[index, "gw_xA"] = 0.0
530
+ gw_calc_df.loc[index, "gw_CS"] = 0.0
531
+ gw_calc_df.loc[index, "gw_cbit"] = 0.0
532
+ gw_calc_df.loc[index, "gw_cbitr"] = 0.0
533
+ continue
534
+
535
+ base_gw_mins = gw_calc_df.loc[index, f"{gw}_xMins"]
536
+ mins_per_match = (
537
+ base_gw_mins * 0.97
538
+ if len(my_matches) > 1 and base_gw_mins > 35
539
+ else base_gw_mins
540
+ )
541
+
542
+ total_gw_pts = 0
543
+ total_gw_xg = 0
544
+ total_gw_xa = 0
545
+ total_gw_cs = 0
546
+ total_gw_cbit = 0
547
+ total_gw_cbitr = 0
548
+
549
+ for _, match_row in my_matches.iterrows():
550
+ stats = calculate_single_match_points(
551
+ player=player,
552
+ match_row=match_row,
553
+ xMins_in_match=mins_per_match,
554
+ points_config=points_config,
555
+ player_penalty_shares=player_penalty_shares,
556
+ is_gk=(player["element_type"] == 1),
557
+ is_def=(player["element_type"] == 2),
558
+ is_mid=(player["element_type"] == 3),
559
+ is_fwd=(player["element_type"] == 4),
560
+ )
561
+ total_gw_pts += stats["pts"]
562
+ total_gw_xg += stats["xG"]
563
+ total_gw_xa += stats["xA"]
564
+ total_gw_cs += stats["CS"]
565
+ total_gw_cbit += stats["cbit"]
566
+ total_gw_cbitr += stats["cbitr"]
567
+
568
+ is_home = player_team_num == match_row["home_team_num"]
569
+ opp_num = (
570
+ match_row["away_team_num"]
571
+ if is_home
572
+ else match_row["home_team_num"]
573
+ )
574
+ match_id = (
575
+ f"{match_row['home_team_num']}_vs_{match_row['away_team_num']}"
576
+ )
577
+
578
+ match_projections_col[index][match_id] = {
579
+ "opponent_team_id": int(opp_num),
580
+ "is_home": bool(is_home),
581
+ "default_gw": int(gw),
582
+ "Pts": round(stats["pts"], 3),
583
+ "xMins": round(mins_per_match, 1),
584
+ "xG": round(stats["xG"], 3),
585
+ "xA": round(stats["xA"], 3),
586
+ "CS": round(stats["CS"], 3),
587
+ }
588
+
589
+ gw_calc_df.loc[index, "base_pts"] = total_gw_pts
590
+ gw_calc_df.loc[index, "gw_xG"] = total_gw_xg
591
+ gw_calc_df.loc[index, "gw_xA"] = total_gw_xa
592
+ gw_calc_df.loc[index, "gw_CS"] = total_gw_cs
593
+ gw_calc_df.loc[index, "gw_cbit"] = total_gw_cbit
594
+ gw_calc_df.loc[index, "gw_cbitr"] = total_gw_cbitr
595
+
596
+ gw_calc_df = apply_team_skepticism(gw_calc_df, team_skepticism)
597
+ gw_calc_df["total_pts"] = gw_calc_df["base_pts"]
598
+
599
+ final_df_output[f"{gw}_xMins"] = round(gw_calc_df[f"{gw}_xMins"], 0)
600
+ final_df_output[f"{gw}_Pts"] = round(gw_calc_df["total_pts"], 2)
601
+ final_df_output[f"{gw}_xG"] = round(gw_calc_df["gw_xG"], 2)
602
+ final_df_output[f"{gw}_xA"] = round(gw_calc_df["gw_xA"], 2)
603
+ final_df_output[f"{gw}_CS"] = gw_calc_df["gw_CS"]
604
+ final_df_output[f"{gw}_cbit"] = gw_calc_df["gw_cbit"]
605
+ final_df_output[f"{gw}_cbitr"] = gw_calc_df["gw_cbitr"]
606
+ continuous_xMins_progression = next_gw_continuous_xMins.copy()
607
+
608
+ final_df_output["Total Points"] = final_df_output.filter(like="_Pts").sum(axis=1)
609
+ final_df_output["Average Points"] = round(
610
+ (final_df_output.filter(like="_Pts").sum(axis=1)) / len(unique_gws), 2
611
+ )
612
+ final_df_output["match_projections"] = pd.Series(match_projections_col)
613
+ return final_df_output
ewmapois_model.csv ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GW,home_team,away_team,expected_home_goals,expected_away_goals,home_win_prob,draw_prob,away_win_prob,home_clean_sheet_odds,away_clean_sheet_odds,mc_home_goals_mean,mc_home_goals_std,mc_away_goals_mean,mc_away_goals_std,mc_home_assists_xa_mean,mc_home_assists_xa_std,mc_away_assists_xa_mean,mc_away_assists_xa_std,mc_home_CBIT_mean,mc_home_CBIT_std,mc_away_CBIT_mean,mc_away_CBIT_std,mc_home_CBITR_mean,mc_home_CBITR_std,mc_away_CBITR_mean,mc_away_CBITR_std,mc_home_keeper_saves_mean,mc_home_keeper_saves_std,mc_away_keeper_saves_mean,mc_away_keeper_saves_std,mc_home_yc_mean,mc_away_yc_mean,mc_home_rc_mean,mc_away_rc_mean,home_strength_xa_strength,away_strength_xa_strength,home_strength_cbit_strength,away_strength_cbit_strength,home_strength_cbitr_strength,away_strength_cbitr_strength,home_strength_keeper_save_strength,away_strength_keeper_save_strength,home_strength_possession_strength,away_strength_possession_strength,home_strength_yc_strength,away_strength_yc_strength,home_strength_rc_strength,away_strength_rc_strength,home_strength_attack_strength,home_strength_defense_strength,away_strength_attack_strength,away_strength_defense_strength,exact_score_prob_0-0,exact_score_prob_0-1,exact_score_prob_0-2,exact_score_prob_0-3,exact_score_prob_1-0,exact_score_prob_1-1,exact_score_prob_1-2,exact_score_prob_2-0,exact_score_prob_2-1,exact_score_prob_3-0,exact_score_prob_3-1,exact_score_prob_4-0
2
+ 32,Arsenal,AFC Bournemouth,2.11,0.8,0.677,0.193,0.13,0.4503,0.1212,2.11,1.45,0.8,0.89,1.25,1.12,0.6,0.77,48.12,6.99,56.51,7.49,101.02,10.12,106.31,10.35,2.94,1.71,3.9,1.98,1.62,2.42,0.05,0.07,1.28,0.91,40.52,54.81,86.13,106.79,2.02,3.14,48.74,37.77,1.24,2.53,0.15,0.23,1.74,1.81,1.45,1.01,,,,,0.1153,0.0918,,0.1215,0.097,0.0855,,
3
+ 32,Brentford,Everton,1.44,1.04,0.4621,0.2649,0.2729,0.3543,0.238,1.44,1.2,1.04,1.02,0.98,0.99,0.73,0.84,46.86,6.85,53.96,7.3,99.66,9.98,107.61,10.35,2.79,1.67,3.64,1.92,1.71,2.04,0.06,0.07,0.98,0.73,53.71,58.89,105.25,105.85,3.74,3.39,35.79,35.19,2.02,2.2,0.0,0.02,1.44,1.09,1.13,1.23,,0.0876,,,0.1212,0.1254,,0.0869,0.0901,,,
4
+ 32,Burnley,Brighton and Hove Albion,0.96,1.75,0.2065,0.2352,0.5583,0.1744,0.3821,0.96,0.98,1.75,1.32,1.0,0.99,0.95,0.99,48.98,6.95,54.33,7.34,98.75,9.95,103.08,10.23,2.92,1.72,3.05,1.75,1.9,1.93,0.06,0.06,0.98,1.06,49.63,50.78,94.86,98.02,2.35,2.49,46.88,47.62,1.66,1.78,0.04,0.12,0.92,0.79,1.38,1.17,0.0666,0.1165,0.1016,,,0.1118,0.0978,,,,,
5
+ 32,Chelsea,Manchester City,1.46,1.53,0.3618,0.243,0.3952,0.2158,0.2328,1.46,1.21,1.53,1.24,0.93,0.97,1.02,1.01,51.39,7.24,55.15,7.46,101.25,10.08,102.84,10.11,3.39,1.84,3.67,1.94,2.01,1.91,0.06,0.06,1.07,1.43,44.2,37.37,92.94,80.6,2.86,2.11,50.18,54.61,2.14,1.02,0.11,0.13,1.69,1.18,1.82,1.43,,0.0771,,,0.0733,0.1121,0.0861,,0.0818,,,
6
+ 32,Crystal Palace,Newcastle United,1.42,1.33,0.3926,0.2546,0.3529,0.2632,0.2413,1.42,1.19,1.33,1.16,1.03,1.03,0.91,0.94,49.19,6.96,53.69,7.34,102.15,10.19,106.35,10.4,3.11,1.76,3.38,1.84,1.78,1.94,0.06,0.06,0.94,1.11,61.32,47.92,112.2,96.62,2.65,3.04,37.3,42.06,1.67,1.69,0.15,0.0,1.22,1.14,1.52,1.06,,0.0849,,,0.0904,0.1204,0.0804,,0.0857,,,
7
+ 32,Liverpool,Fulham,1.99,1.07,0.5861,0.2142,0.1996,0.342,0.1368,1.99,1.41,1.07,1.04,1.32,1.16,0.75,0.87,48.04,6.97,58.14,7.73,97.54,9.75,107.49,10.4,2.9,1.7,3.89,1.95,1.63,2.21,0.06,0.06,1.44,0.88,45.45,55.07,95.08,104.31,2.96,3.03,51.51,45.23,1.34,1.98,0.17,0.0,1.69,1.21,1.3,1.05,,,,,0.0932,0.0997,,0.0925,0.0993,,0.0658,
8
+ 32,Manchester United,Leeds United,1.93,1.1,0.5684,0.2196,0.212,0.3343,0.1449,1.93,1.39,1.1,1.05,1.07,1.03,0.99,1.0,51.07,7.13,54.11,7.31,102.37,10.18,103.16,10.04,2.97,1.71,3.45,1.86,1.84,2.03,0.06,0.07,0.94,1.39,52.35,45.49,104.31,89.82,2.78,1.72,45.17,50.45,2.19,1.44,0.03,0.0,1.53,1.15,1.26,0.98,,,,,0.0937,0.1024,,0.0904,0.099,,0.0638,
9
+ 32,Nottingham Forest,Aston Villa,1.21,1.19,0.3675,0.2758,0.3567,0.3035,0.2968,1.21,1.1,1.19,1.09,0.88,0.95,0.83,0.89,48.36,7.01,52.17,7.25,99.68,10.03,105.31,10.2,2.92,1.7,3.24,1.84,1.96,1.96,0.06,0.06,0.75,0.96,63.78,44.39,110.69,89.07,3.1,3.03,33.39,42.87,2.59,1.7,0.0,0.27,1.12,1.11,1.32,1.14,0.0899,0.1075,,,0.1095,0.1303,,,0.0792,,,
10
+ 32,Sunderland,Tottenham Hotspur,1.26,1.2,0.3767,0.2724,0.3509,0.3007,0.285,1.26,1.12,1.2,1.1,1.05,1.02,0.85,0.91,48.45,6.93,53.12,7.34,98.18,9.97,104.19,10.17,2.8,1.66,3.15,1.77,1.86,2.05,0.06,0.06,0.85,0.97,60.41,52.96,106.56,100.34,2.06,3.26,39.44,46.89,2.65,1.81,0.06,0.01,0.92,1.04,1.26,0.9,0.0856,0.1031,,,0.1077,0.1291,,,0.0811,,,
11
+ 32,West Ham United,Wolverhampton Wanderers,1.53,1.03,0.4879,0.2563,0.2558,0.3566,0.2169,1.53,1.24,1.03,1.02,0.97,0.98,0.76,0.87,46.74,6.8,52.78,7.21,96.33,9.75,104.1,10.11,2.64,1.63,3.34,1.82,1.7,1.99,0.05,0.06,0.81,0.69,57.65,54.48,103.85,105.94,3.7,2.86,38.28,41.79,1.98,1.67,0.01,0.02,1.2,0.95,0.98,0.97,,0.0799,,,0.1183,0.1217,,0.0903,0.0931,,,
12
+ 33,Aston Villa,Sunderland,1.56,0.8,0.5524,0.2544,0.1932,0.4472,0.2102,1.56,1.25,0.8,0.9,1.05,1.02,0.77,0.88,47.69,6.91,54.06,7.39,99.58,9.94,104.21,10.19,2.59,1.6,3.32,1.84,1.77,2.33,0.06,0.06,0.96,0.85,44.39,60.41,89.07,106.56,3.03,2.06,42.87,39.44,1.7,2.65,0.27,0.06,1.32,1.14,0.92,1.04,0.0939,,,,0.1468,0.1179,,0.1143,0.092,,,
13
+ 33,Brentford,Fulham,1.69,1.2,0.4893,0.24,0.2707,0.3025,0.1848,1.69,1.3,1.2,1.09,1.06,1.02,0.81,0.91,48.04,6.93,53.79,7.33,97.52,9.83,107.45,10.47,2.94,1.72,3.58,1.87,1.77,2.07,0.06,0.06,0.98,0.88,53.71,55.07,105.25,104.31,3.74,3.03,35.79,45.23,2.02,1.98,0.0,0.0,1.44,1.09,1.3,1.05,,,,,0.0945,0.1127,0.0675,0.0797,0.0953,,,
14
+ 33,Chelsea,Manchester United,1.82,1.29,0.4974,0.2295,0.2731,0.2746,0.1627,1.82,1.35,1.29,1.14,1.06,1.02,0.81,0.9,48.33,6.98,55.17,7.36,98.41,9.97,102.81,10.12,3.07,1.76,3.78,1.94,1.87,2.2,0.05,0.06,1.07,0.94,44.2,52.35,92.94,104.31,2.86,2.78,50.18,45.17,2.14,2.19,0.11,0.03,1.69,1.18,1.53,1.15,,,,,0.0812,0.1047,0.0678,0.0737,0.0952,,,
15
+ 33,Crystal Palace,West Ham United,1.59,1.06,0.4986,0.2499,0.2515,0.3482,0.2032,1.59,1.26,1.06,1.03,1.07,1.03,0.75,0.86,47.43,6.91,53.88,7.31,99.54,9.86,106.15,10.28,2.76,1.66,3.49,1.88,1.65,2.07,0.05,0.06,0.94,0.81,61.32,57.65,112.2,103.85,2.65,3.7,37.3,38.28,1.67,1.98,0.15,0.01,1.22,1.14,1.2,0.95,,0.0748,,,0.1129,0.1188,,0.0898,0.0948,,,
16
+ 33,Everton,Liverpool,1.15,1.37,0.3131,0.2664,0.4205,0.2533,0.3178,1.15,1.07,1.37,1.17,0.84,0.91,1.0,1.0,51.36,7.18,52.1,7.1,102.76,10.24,104.31,10.24,3.35,1.84,3.25,1.8,1.97,1.81,0.05,0.06,0.73,1.44,58.89,45.45,105.85,95.08,3.39,2.96,35.19,51.51,2.2,1.34,0.02,0.17,1.13,1.23,1.69,1.21,0.0804,0.1107,,,0.0924,0.1266,0.087,,,,,
17
+ 33,Leeds United,Wolverhampton Wanderers,1.6,1.0,0.5134,0.2498,0.2368,0.3679,0.202,1.6,1.26,1.0,1.0,1.33,1.16,0.75,0.86,46.72,6.88,57.52,7.6,96.25,9.8,107.18,10.45,2.5,1.57,3.5,1.85,1.6,2.13,0.05,0.07,1.39,0.69,45.49,54.48,89.82,105.94,1.72,2.86,50.45,41.79,1.44,1.67,0.0,0.02,1.26,0.98,0.98,0.97,,0.0744,,,0.119,0.1187,,0.0951,0.0951,,,
18
+ 33,Manchester City,Arsenal,1.24,1.22,0.3683,0.273,0.3587,0.2967,0.2908,1.24,1.11,1.22,1.1,0.99,1.0,0.87,0.94,50.31,7.04,57.95,7.57,101.79,10.29,106.06,10.32,3.26,1.8,3.85,1.95,1.76,1.88,0.06,0.07,1.43,1.28,37.37,40.52,80.6,86.13,2.11,2.02,54.61,48.74,1.02,1.24,0.13,0.15,1.82,1.43,1.74,1.81,0.0861,0.105,,,0.1067,0.1293,,,0.08,,,
19
+ 33,Newcastle United,AFC Bournemouth,1.85,1.36,0.489,0.2267,0.2843,0.256,0.1578,1.85,1.36,1.36,1.17,1.13,1.06,0.83,0.91,48.07,6.89,55.0,7.45,101.17,10.13,106.82,10.31,3.02,1.74,3.69,1.92,1.81,2.22,0.06,0.07,1.11,0.91,47.92,54.81,96.62,106.79,3.04,3.14,42.06,37.77,1.69,2.53,0.0,0.23,1.52,1.06,1.45,1.01,,,,,0.0747,0.1015,0.0692,0.0688,0.0938,,,
20
+ 33,Nottingham Forest,Burnley,1.75,0.83,0.5925,0.233,0.1746,0.4379,0.1744,1.75,1.32,0.83,0.91,1.02,1.02,0.81,0.89,48.52,6.91,52.08,7.31,98.22,9.92,105.28,10.19,2.62,1.63,3.14,1.76,1.79,2.03,0.05,0.06,0.75,0.98,63.78,49.63,110.69,94.86,3.1,2.35,33.39,46.88,2.59,1.66,0.0,0.04,1.12,1.11,0.92,0.79,0.0763,,,,0.1335,0.11,,0.1164,0.0962,,,
21
+ 33,Tottenham Hotspur,Brighton and Hove Albion,1.32,1.54,0.3265,0.248,0.4256,0.2146,0.2677,1.32,1.15,1.54,1.24,0.99,0.99,0.95,0.98,48.98,7.01,54.23,7.47,98.93,9.8,102.9,10.17,3.04,1.75,3.3,1.83,1.88,1.99,0.06,0.07,0.97,1.06,52.96,50.78,100.34,98.02,3.26,2.49,46.89,47.62,1.81,1.78,0.01,0.12,1.26,0.9,1.38,1.17,,0.0885,,,0.0758,0.1164,0.0897,,0.0768,,,
22
+ 34,Burnley,Manchester City,0.79,2.29,0.1125,0.1746,0.7129,0.1009,0.4545,0.79,0.89,2.29,1.51,0.91,0.95,1.13,1.06,51.36,7.25,54.36,7.4,101.22,10.09,102.89,10.13,3.37,1.83,3.0,1.73,2.06,1.75,0.05,0.06,0.98,1.43,49.63,37.37,94.86,80.6,2.35,2.11,46.88,54.61,1.66,1.02,0.04,0.13,0.92,0.79,1.82,1.43,,0.1053,0.1206,0.0922,,0.0828,0.0951,,,,,
23
+ 34,Arsenal,Newcastle United,2.02,0.84,0.6484,0.2043,0.1473,0.4317,0.1329,2.02,1.42,0.84,0.92,1.21,1.1,0.69,0.84,49.18,7.02,56.67,7.57,101.95,10.14,106.4,10.19,3.07,1.75,3.84,1.95,1.58,2.14,0.06,0.06,1.28,1.11,40.52,47.92,86.13,96.62,2.02,3.04,48.74,42.06,1.24,1.69,0.15,0.0,1.74,1.81,1.52,1.06,,,,,0.1159,0.0971,,0.1168,0.0981,0.0786,,
24
+ 34,Brighton and Hove Albion,Chelsea,1.44,1.44,0.3742,0.2484,0.3774,0.2359,0.2375,1.44,1.2,1.44,1.2,1.05,1.03,0.86,0.93,49.04,7.08,55.02,7.31,97.99,10.0,103.92,10.22,3.21,1.8,3.48,1.86,1.89,2.11,0.06,0.06,1.06,1.07,50.78,44.2,98.02,92.94,2.49,2.86,47.62,50.18,1.78,2.14,0.12,0.11,1.38,1.17,1.69,1.18,,0.081,,,0.0806,0.1162,0.084,,0.0836,,,
25
+ 34,AFC Bournemouth,Leeds United,1.82,1.24,0.5106,0.2292,0.2601,0.2892,0.1615,1.82,1.35,1.24,1.11,1.04,1.02,1.04,1.02,51.12,7.18,53.45,7.26,102.62,10.09,105.68,10.16,2.94,1.7,3.38,1.84,1.95,2.04,0.06,0.06,0.91,1.39,54.81,45.49,106.79,89.82,3.14,1.72,37.77,50.45,2.53,1.44,0.23,0.0,1.45,1.01,1.26,0.98,,,,,0.0852,0.1055,0.0655,0.0776,0.0963,,,
26
+ 34,Fulham,Aston Villa,1.41,1.26,0.4037,0.2588,0.3375,0.2825,0.2449,1.41,1.19,1.26,1.12,0.95,0.98,0.84,0.92,48.42,6.95,53.44,7.27,99.63,10.06,102.2,10.05,2.92,1.72,3.46,1.84,1.88,2.05,0.05,0.06,0.88,0.96,55.07,44.39,104.31,89.07,3.03,3.03,45.23,42.87,1.98,1.7,0.0,0.27,1.3,1.05,1.32,1.14,,0.0876,,,0.0975,0.1229,0.0778,,0.0866,,,
27
+ 34,Liverpool,Crystal Palace,1.83,1.01,0.5653,0.2279,0.2068,0.3642,0.161,1.83,1.35,1.01,1.01,1.27,1.12,0.79,0.89,48.15,7.0,57.92,7.62,101.81,10.06,107.69,10.45,2.82,1.67,3.82,1.98,1.63,2.14,0.05,0.07,1.44,0.94,45.45,61.32,95.08,112.2,2.96,2.65,51.51,37.3,1.34,1.67,0.17,0.15,1.69,1.21,1.22,1.14,,,,,0.1072,0.108,,0.0978,0.0988,,0.0601,
28
+ 34,Manchester United,Brentford,1.73,1.25,0.4874,0.236,0.2766,0.2865,0.1769,1.73,1.32,1.25,1.12,1.02,1.0,0.82,0.9,48.31,6.9,54.03,7.24,102.97,10.04,103.15,10.1,3.01,1.75,3.76,1.95,1.78,2.09,0.06,0.07,0.94,0.98,52.35,53.71,104.31,105.25,2.78,3.74,45.17,35.79,2.19,2.02,0.03,0.0,1.53,1.15,1.44,1.09,,,,,0.0879,0.1096,0.0686,0.076,0.095,,,
29
+ 34,Sunderland,Nottingham Forest,1.02,1.08,0.3351,0.2997,0.3652,0.3411,0.3617,1.02,1.01,1.08,1.04,0.96,0.97,0.76,0.86,47.07,6.95,53.09,7.27,100.89,10.05,104.15,10.17,2.63,1.63,3.08,1.77,1.81,2.13,0.05,0.06,0.85,0.75,60.41,63.78,106.56,110.69,2.06,3.1,39.44,33.39,2.65,2.59,0.06,0.0,0.92,1.04,1.12,1.11,0.1232,0.1329,,,0.1256,0.1348,0.0726,,,,,
30
+ 34,West Ham United,Everton,1.2,1.19,0.3639,0.2766,0.3594,0.3033,0.3005,1.2,1.1,1.19,1.09,0.87,0.94,0.8,0.89,46.86,6.81,52.78,7.21,99.77,10.02,103.99,10.18,2.82,1.67,3.41,1.86,1.77,2.01,0.06,0.06,0.81,0.73,57.65,58.89,103.85,105.85,3.7,3.39,38.28,35.19,1.98,2.2,0.01,0.02,1.2,0.95,1.13,1.23,0.091,0.1089,,,0.1097,0.1306,,,0.0786,,,
31
+ 34,Wolverhampton Wanderers,Tottenham Hotspur,1.34,1.3,0.3787,0.2617,0.3596,0.2737,0.2627,1.34,1.16,1.3,1.14,0.95,0.97,0.89,0.94,48.45,7.04,51.9,7.21,97.87,9.83,101.06,10.06,2.86,1.67,3.14,1.77,1.75,1.97,0.05,0.06,0.69,0.97,54.48,52.96,105.94,100.34,2.86,3.26,41.79,46.89,1.67,1.81,0.02,0.01,0.98,0.97,1.26,0.9,,0.0933,,,0.0962,0.1244,0.0807,,0.0832,,,
32
+ 35,Arsenal,Fulham,2.04,0.72,0.6839,0.1957,0.1204,0.4876,0.1294,2.04,1.43,0.72,0.85,1.24,1.11,0.58,0.77,47.77,6.9,56.63,7.53,97.77,9.93,106.13,10.34,2.84,1.67,3.91,1.96,1.53,2.26,0.05,0.06,1.28,0.88,40.52,55.07,86.13,104.31,2.02,3.03,48.74,45.23,1.24,1.98,0.15,0.0,1.74,1.81,1.3,1.05,,,,,0.1292,0.0926,,0.1319,0.0948,0.0899,,
33
+ 35,Aston Villa,Tottenham Hotspur,1.81,1.1,0.54,0.2301,0.23,0.3321,0.1633,1.81,1.35,1.1,1.05,1.11,1.05,0.82,0.91,48.42,6.95,54.07,7.39,97.86,9.95,104.29,10.18,2.86,1.69,3.51,1.88,1.76,2.16,0.06,0.06,0.96,0.97,44.39,52.96,89.07,100.34,3.03,3.26,42.87,46.89,1.7,1.81,0.27,0.01,1.32,1.14,1.26,0.9,,0.0598,,,0.0984,0.1082,,0.089,0.0982,,,
34
+ 35,AFC Bournemouth,Crystal Palace,1.56,1.21,0.4554,0.2497,0.2949,0.299,0.2099,1.56,1.25,1.21,1.1,0.98,0.99,0.88,0.94,48.25,7.0,53.57,7.38,101.81,10.23,105.65,10.28,2.87,1.71,3.5,1.89,1.83,2.05,0.06,0.06,0.91,0.94,54.81,61.32,106.79,112.2,3.14,2.65,37.77,37.3,2.53,1.67,0.23,0.15,1.45,1.01,1.22,1.14,,0.0759,,,0.0981,0.1181,,0.0765,0.0923,,,
35
+ 35,Brentford,West Ham United,1.87,1.11,0.552,0.2253,0.2227,0.3311,0.1544,1.87,1.37,1.11,1.05,1.1,1.05,0.77,0.88,47.4,6.89,53.83,7.34,99.5,10.03,107.4,10.5,2.87,1.69,3.66,1.92,1.69,2.13,0.05,0.06,0.98,0.81,53.71,57.65,105.25,103.85,3.74,3.7,35.79,38.28,2.02,1.98,0.0,0.01,1.44,1.09,1.2,0.95,,,,,0.0956,0.1054,,0.0892,0.0986,,0.0614,
36
+ 35,Chelsea,Nottingham Forest,1.88,0.95,0.5923,0.2217,0.186,0.3874,0.1526,1.88,1.37,0.95,0.97,1.08,1.04,0.72,0.85,47.02,6.9,55.04,7.48,100.85,9.93,103.01,10.26,2.69,1.62,3.84,1.96,1.72,2.34,0.06,0.06,1.07,0.75,44.2,63.78,92.94,110.69,2.86,3.1,50.18,33.39,2.14,2.59,0.11,0.0,1.69,1.18,1.12,1.11,,,,,0.1113,0.1053,,0.1045,0.0991,0.0655,,
37
+ 35,Everton,Manchester City,0.97,1.48,0.2489,0.2626,0.4885,0.2287,0.3785,0.97,0.99,1.48,1.21,0.76,0.87,1.0,1.0,51.34,7.24,52.16,7.17,101.3,10.15,104.33,10.16,3.43,1.86,3.12,1.74,2.04,1.68,0.05,0.06,0.73,1.43,58.89,37.37,105.85,80.6,3.39,2.11,35.19,54.61,2.2,1.02,0.02,0.13,1.13,1.23,1.82,1.43,0.0864,0.1278,0.0942,,,0.1239,0.0915,,,,,
38
+ 35,Leeds United,Burnley,1.96,0.94,0.6112,0.214,0.1749,0.391,0.1414,1.96,1.4,0.94,0.97,1.39,1.19,0.89,0.94,48.7,7.0,57.55,7.58,98.14,9.88,107.23,10.32,2.5,1.58,3.4,1.84,1.66,2.21,0.05,0.07,1.39,0.98,45.49,49.63,89.82,94.86,1.72,2.35,50.45,46.88,1.44,1.66,0.0,0.04,1.26,0.98,0.92,0.79,,,,,0.1083,0.1014,,0.1058,0.0993,0.069,,
39
+ 35,Manchester United,Liverpool,1.55,1.47,0.3974,0.2413,0.3612,0.2294,0.2112,1.55,1.25,1.47,1.21,0.96,0.98,1.04,1.01,51.36,7.08,54.0,7.35,102.68,10.15,103.27,10.14,3.3,1.82,3.65,1.92,1.92,1.92,0.06,0.06,0.94,1.44,52.35,45.45,104.31,95.08,2.78,2.96,45.17,51.51,2.19,1.34,0.03,0.17,1.53,1.15,1.69,1.21,,0.0714,,,0.0754,0.1108,0.0817,,0.0862,,,
40
+ 35,Newcastle United,Brighton and Hove Albion,1.6,1.3,0.4429,0.2445,0.3126,0.2714,0.2024,1.6,1.26,1.3,1.14,1.06,1.03,0.89,0.96,48.97,7.0,55.11,7.51,99.07,10.01,106.59,10.27,2.98,1.72,3.59,1.89,1.79,2.03,0.06,0.07,1.11,1.06,47.92,50.78,96.62,98.02,3.04,2.49,42.06,47.62,1.69,1.78,0.0,0.12,1.52,1.06,1.38,1.17,,0.0717,,,0.0878,0.1143,0.0746,,0.0914,,,
41
+ 35,Wolverhampton Wanderers,Sunderland,1.15,0.95,0.4036,0.2975,0.2989,0.3884,0.3166,1.15,1.07,0.95,0.97,0.87,0.93,0.83,0.91,47.66,6.91,51.93,7.15,99.59,9.97,101.35,10.06,2.57,1.58,2.96,1.74,1.74,2.08,0.06,0.06,0.69,0.85,54.48,60.41,105.94,106.56,2.86,2.06,41.79,39.44,1.67,2.65,0.02,0.06,0.98,0.97,0.92,1.04,0.1228,0.1164,,,0.1416,0.1336,,0.0813,,,,
42
+ 36,Brighton and Hove Albion,Wolverhampton Wanderers,1.76,0.83,0.5936,0.2318,0.1746,0.4352,0.1724,1.76,1.33,0.83,0.91,1.14,1.07,0.69,0.83,46.67,6.79,55.01,7.42,96.32,9.72,103.67,10.25,2.6,1.6,3.52,1.9,1.63,2.12,0.05,0.06,1.06,0.69,50.78,54.48,98.02,105.94,2.49,2.86,47.62,41.79,1.78,1.67,0.12,0.02,1.38,1.17,0.98,0.97,0.0749,,,,0.132,0.1096,,0.1159,0.0965,,,
43
+ 36,Burnley,Aston Villa,0.99,1.67,0.224,0.2429,0.5331,0.1882,0.3713,0.99,1.0,1.67,1.29,1.01,1.02,0.94,0.97,48.38,7.04,54.25,7.34,99.55,10.05,102.96,10.3,2.91,1.71,3.1,1.76,1.9,2.0,0.06,0.06,0.98,0.96,49.63,44.39,94.86,89.07,2.35,3.03,46.88,42.87,1.66,1.7,0.04,0.27,0.92,0.79,1.32,1.14,0.0698,0.1168,0.0974,,,0.1155,0.0966,,,,,
44
+ 36,Manchester City,Crystal Palace,1.96,0.86,0.6329,0.2106,0.1565,0.4249,0.1405,1.96,1.4,0.86,0.93,1.28,1.14,0.72,0.85,48.17,6.93,57.89,7.64,101.81,10.09,105.94,10.26,2.77,1.64,3.94,2.0,1.51,2.18,0.05,0.06,1.43,0.94,37.37,61.32,80.6,112.2,2.11,2.65,54.61,37.3,1.02,1.67,0.13,0.15,1.82,1.43,1.22,1.14,,,,,0.1173,0.1001,,0.1149,0.0984,0.0752,,
45
+ 36,Crystal Palace,Everton,1.22,0.99,0.4149,0.2872,0.2979,0.3714,0.2939,1.22,1.11,0.99,1.0,0.97,0.99,0.73,0.86,46.97,6.85,53.68,7.37,99.55,10.06,106.17,10.33,2.68,1.66,3.4,1.84,1.66,2.01,0.05,0.06,0.94,0.73,61.32,58.89,112.2,105.85,2.65,3.39,37.3,35.19,1.67,2.2,0.15,0.02,1.22,1.14,1.13,1.23,0.109,0.1083,,,0.1338,0.1322,,0.0818,,,,
46
+ 36,Fulham,AFC Bournemouth,1.58,1.38,0.4222,0.2433,0.3345,0.2514,0.2062,1.58,1.26,1.38,1.18,1.01,1.01,0.83,0.91,47.94,6.94,53.46,7.29,101.15,10.06,102.42,9.99,3.05,1.75,3.46,1.86,1.88,2.19,0.05,0.06,0.88,0.91,55.07,54.81,104.31,106.79,3.03,3.14,45.23,37.77,1.98,2.53,0.0,0.23,1.3,1.05,1.45,1.01,,0.0716,,,0.0819,0.1129,0.078,,0.0892,,,
47
+ 36,Liverpool,Chelsea,1.76,1.4,0.461,0.232,0.3069,0.2473,0.1725,1.76,1.33,1.4,1.18,1.25,1.12,0.85,0.93,48.99,7.07,57.98,7.66,98.11,10.0,107.57,10.41,3.24,1.79,3.87,1.94,1.83,2.22,0.05,0.07,1.44,1.07,45.45,44.2,95.08,92.94,2.96,2.86,51.51,50.18,1.34,2.14,0.17,0.11,1.69,1.21,1.69,1.18,,,,,0.0751,0.1046,0.0732,0.0659,0.092,,,
48
+ 36,Manchester City,Brentford,2.06,1.0,0.6166,0.2065,0.1769,0.3666,0.128,2.06,1.43,1.0,1.0,1.29,1.12,0.73,0.86,48.38,6.93,58.06,7.64,102.98,10.12,106.08,10.28,2.94,1.72,4.11,2.02,1.55,2.26,0.06,0.06,1.43,0.98,37.37,53.71,80.6,105.25,2.11,3.74,54.61,35.79,1.02,2.02,0.13,0.0,1.82,1.43,1.44,1.09,,,,,0.0966,0.0967,,0.0991,0.0995,,0.0682,
49
+ 36,Nottingham Forest,Newcastle United,1.3,1.37,0.355,0.2591,0.3859,0.2538,0.2714,1.3,1.14,1.37,1.17,0.91,0.94,0.9,0.94,49.32,7.03,51.95,7.23,102.06,10.03,105.25,10.16,3.12,1.76,3.23,1.81,1.91,1.9,0.06,0.06,0.75,1.11,63.78,47.92,110.69,96.62,3.1,3.04,33.39,42.06,2.59,1.69,0.0,0.0,1.12,1.11,1.52,1.06,,0.0946,,,0.0899,0.123,0.0845,,0.0803,,,
50
+ 36,Tottenham Hotspur,Leeds United,1.58,1.4,0.4192,0.2424,0.3384,0.2465,0.2052,1.58,1.26,1.4,1.18,1.07,1.04,1.08,1.05,51.04,7.07,54.28,7.23,102.47,10.16,102.67,10.26,2.99,1.76,3.23,1.79,1.87,1.95,0.06,0.06,0.97,1.39,52.96,45.49,100.34,89.82,3.26,1.72,46.89,50.45,1.81,1.44,0.01,0.0,1.26,0.9,1.26,0.98,,0.0709,,,0.0802,0.112,0.0786,,0.0888,,,
51
+ 36,Sunderland,Manchester United,0.98,1.47,0.2533,0.2633,0.4834,0.2309,0.3744,0.98,0.99,1.47,1.21,0.94,0.98,0.85,0.91,48.35,7.0,53.21,7.35,98.42,9.93,104.32,10.12,3.02,1.74,3.05,1.74,1.96,2.0,0.06,0.07,0.85,0.94,60.41,52.35,106.56,104.31,2.06,2.78,39.44,45.17,2.65,2.19,0.06,0.03,0.92,1.04,1.53,1.15,0.0863,0.1269,0.0929,,,0.1243,0.0912,,,,,
52
+ 36,West Ham United,Arsenal,0.82,1.84,0.162,0.2227,0.6153,0.1593,0.4415,0.82,0.9,1.84,1.36,0.65,0.81,1.01,1.01,50.3,7.07,52.69,7.19,101.43,10.06,103.89,10.19,3.37,1.82,3.17,1.77,2.04,1.65,0.06,0.06,0.81,1.28,57.65,40.52,103.85,86.13,3.7,2.02,38.28,48.74,1.98,1.24,0.01,0.15,1.2,0.95,1.74,1.81,,0.1293,0.1187,0.0727,,0.1055,0.097,,,,,
53
+ 37,Arsenal,Burnley,2.7,0.51,0.835,0.1176,0.0474,0.6029,0.067,2.7,1.64,0.51,0.71,1.35,1.17,0.62,0.78,48.48,6.94,56.74,7.58,98.11,9.92,106.39,10.25,2.52,1.58,3.78,1.96,1.49,2.31,0.05,0.06,1.28,0.98,40.52,49.63,86.13,94.86,2.02,2.35,48.74,46.88,1.24,1.66,0.15,0.04,1.74,1.81,0.92,0.79,,,,,0.1094,,,0.1476,0.0747,0.1329,,0.0898
54
+ 37,Aston Villa,Liverpool,1.34,1.49,0.3429,0.2504,0.4067,0.2265,0.2609,1.34,1.16,1.49,1.22,0.99,0.99,1.03,1.0,51.29,7.18,54.05,7.26,102.71,10.04,104.32,10.23,3.31,1.83,3.46,1.89,1.96,1.96,0.05,0.06,0.96,1.44,44.39,45.45,89.07,95.08,3.03,2.96,42.87,51.51,1.7,1.34,0.27,0.17,1.32,1.14,1.69,1.21,,0.0879,,,0.0795,0.1178,0.0876,,0.0792,,,
55
+ 37,AFC Bournemouth,Manchester City,1.24,1.79,0.2658,0.2317,0.5026,0.1668,0.2883,1.24,1.12,1.79,1.34,0.84,0.91,1.07,1.03,51.31,7.2,53.37,7.36,101.31,10.13,105.64,10.3,3.45,1.86,3.44,1.88,2.13,1.8,0.06,0.07,0.91,1.43,54.81,37.37,106.79,80.6,3.14,2.11,37.77,54.61,2.53,1.02,0.23,0.13,1.45,1.01,1.82,1.43,,0.0862,0.0771,,,0.107,0.0959,,0.0666,,,
56
+ 37,Brentford,Crystal Palace,1.55,1.13,0.4715,0.2524,0.2761,0.3245,0.2121,1.55,1.25,1.13,1.06,1.02,1.0,0.84,0.93,48.11,6.91,53.88,7.35,101.91,10.07,107.29,10.31,2.88,1.69,3.51,1.88,1.73,1.99,0.05,0.06,0.98,0.94,53.71,61.32,105.25,112.2,3.74,2.65,35.79,37.3,2.02,1.67,0.0,0.15,1.44,1.09,1.22,1.14,,0.0775,,,0.1068,0.12,,0.0828,0.0931,,,
57
+ 37,Chelsea,Tottenham Hotspur,2.32,1.06,0.6555,0.1855,0.159,0.3466,0.0982,2.32,1.52,1.06,1.03,1.16,1.07,0.8,0.89,48.5,6.96,55.17,7.34,98.07,9.97,103.08,10.1,2.87,1.68,3.8,1.96,1.74,2.24,0.06,0.07,1.07,0.97,44.2,52.96,92.94,100.34,2.86,3.26,50.18,46.89,2.14,1.81,0.11,0.01,1.69,1.18,1.26,0.9,,,,,0.0791,0.0836,,0.0917,0.0971,,0.0751,
58
+ 37,Everton,Sunderland,1.33,0.74,0.5069,0.284,0.2092,0.4752,0.2643,1.33,1.15,0.74,0.86,0.92,0.96,0.75,0.87,47.68,6.85,51.99,7.25,99.8,10.07,104.29,10.19,2.62,1.61,3.12,1.75,1.78,2.11,0.06,0.07,0.73,0.85,58.89,60.41,105.85,106.56,3.39,2.06,35.19,39.44,2.2,2.65,0.02,0.06,1.13,1.23,0.92,1.04,0.1254,0.0936,,,0.1673,0.1242,,0.1112,,,,
59
+ 37,Leeds United,Brighton and Hove Albion,1.32,1.42,0.3503,0.2555,0.3942,0.2423,0.2667,1.32,1.15,1.42,1.19,1.24,1.12,0.92,0.96,48.96,7.01,57.51,7.6,99.01,9.92,107.01,10.37,2.91,1.71,3.37,1.86,1.78,2.08,0.05,0.06,1.39,1.06,45.49,50.78,89.82,98.02,1.72,2.49,50.45,47.62,1.44,1.78,0.0,0.12,1.26,0.98,1.38,1.17,,0.0917,,,0.0855,0.1209,0.0858,,0.08,,,
60
+ 37,Manchester United,Nottingham Forest,1.7,0.98,0.5432,0.24,0.2168,0.376,0.1828,1.7,1.3,0.98,0.99,1.0,1.01,0.73,0.86,46.93,6.9,53.94,7.38,100.72,10.06,103.18,10.07,2.69,1.66,3.62,1.91,1.69,2.24,0.06,0.07,0.94,0.75,52.35,63.78,104.31,110.69,2.78,3.1,45.17,33.39,2.19,2.59,0.03,0.0,1.53,1.15,1.12,1.11,0.0687,,,,0.117,0.1141,,0.0992,0.0971,,,
61
+ 37,Newcastle United,West Ham United,1.98,1.13,0.5699,0.2161,0.214,0.3217,0.1381,1.98,1.41,1.13,1.06,1.16,1.08,0.79,0.88,47.35,6.92,55.01,7.34,99.43,9.99,106.73,10.35,2.82,1.66,3.79,1.94,1.66,2.15,0.05,0.06,1.11,0.81,47.92,57.65,96.62,103.85,3.04,3.7,42.06,38.28,1.69,1.98,0.0,0.01,1.52,1.06,1.2,0.95,,,,,0.0881,0.0996,,0.0871,0.0987,,0.0652,
62
+ 37,Wolverhampton Wanderers,Fulham,1.15,1.34,0.3191,0.2688,0.4121,0.2611,0.3174,1.15,1.07,1.34,1.16,0.9,0.94,0.85,0.92,47.82,6.96,51.96,7.2,97.57,9.8,101.19,10.02,2.89,1.68,3.13,1.78,1.75,1.94,0.06,0.06,0.69,0.88,54.48,55.07,105.94,104.31,2.86,3.03,41.79,45.23,1.67,1.98,0.02,0.0,0.98,0.97,1.3,1.05,0.0828,0.1114,,,0.0952,0.1275,0.0857,,,,,
63
+ 38,Brighton and Hove Albion,Manchester United,1.48,1.31,0.4142,0.252,0.3338,0.271,0.227,1.48,1.22,1.31,1.14,1.07,1.02,0.81,0.9,48.42,6.94,54.88,7.48,98.3,9.88,103.64,10.2,3.06,1.75,3.51,1.87,1.78,2.1,0.06,0.07,1.06,0.94,50.78,52.35,98.02,104.31,2.49,2.78,47.62,45.17,1.78,2.19,0.12,0.03,1.38,1.17,1.53,1.15,,0.0804,,,0.0913,0.1189,0.0778,,0.0883,,,
64
+ 38,Burnley,Wolverhampton Wanderers,1.16,1.23,0.3455,0.2764,0.3782,0.2918,0.3121,1.16,1.08,1.23,1.11,1.1,1.04,0.81,0.9,46.74,6.84,54.31,7.48,96.3,9.89,102.97,10.17,2.56,1.58,3.1,1.77,1.73,2.02,0.05,0.07,0.98,0.69,49.63,54.48,94.86,105.94,2.35,2.86,46.88,41.79,1.66,1.67,0.04,0.02,0.92,0.79,0.98,0.97,0.0909,0.1123,,,0.1062,0.1304,0.0804,,,,,
65
+ 38,Crystal Palace,Arsenal,0.83,1.53,0.205,0.2585,0.5365,0.2175,0.435,0.83,0.91,1.53,1.24,0.71,0.84,0.97,0.99,50.37,7.08,53.61,7.28,101.64,10.14,106.23,10.25,3.29,1.82,3.24,1.81,1.92,1.68,0.06,0.06,0.94,1.28,61.32,40.52,112.2,86.13,2.65,2.02,37.3,48.74,1.67,1.24,0.15,0.15,1.22,1.14,1.74,1.81,0.0945,0.1445,0.1101,,,0.12,0.0916,,,,,
66
+ 38,Fulham,Newcastle United,1.51,1.45,0.3904,0.2444,0.3652,0.2338,0.2208,1.51,1.23,1.45,1.21,1.0,1.01,0.93,0.97,49.4,6.97,53.49,7.34,102.05,10.19,102.35,10.19,3.15,1.78,3.43,1.85,1.83,1.94,0.06,0.06,0.88,1.11,55.07,47.92,104.31,96.62,3.03,3.04,45.23,42.06,1.98,1.69,0.0,0.0,1.3,1.05,1.52,1.06,,0.0751,,,0.0781,0.1132,0.0824,,0.0856,,,
67
+ 38,Liverpool,Brentford,1.91,1.18,0.5438,0.2221,0.2341,0.306,0.1476,1.91,1.38,1.18,1.09,1.29,1.12,0.8,0.9,48.36,6.92,57.94,7.63,103.23,10.17,107.48,10.42,3.01,1.75,3.98,2.0,1.63,2.2,0.05,0.07,1.44,0.98,45.45,53.71,95.08,105.25,2.96,3.74,51.51,35.79,1.34,2.02,0.17,0.0,1.69,1.21,1.44,1.09,,,,,0.0865,0.1022,,0.0827,0.0979,,0.0624,
68
+ 38,Manchester City,Aston Villa,1.96,0.92,0.6164,0.2127,0.1708,0.3966,0.1402,1.96,1.4,0.92,0.96,1.27,1.13,0.72,0.85,48.34,6.96,58.02,7.69,99.61,10.03,106.11,10.32,2.85,1.68,3.99,1.99,1.67,2.24,0.06,0.07,1.43,0.96,37.37,44.39,80.6,89.07,2.11,3.03,54.61,42.87,1.02,1.7,0.13,0.27,1.82,1.43,1.32,1.14,,,,,0.1094,0.1009,,0.1073,0.0992,0.0703,,
69
+ 38,Nottingham Forest,AFC Bournemouth,1.36,1.3,0.3843,0.2597,0.356,0.2719,0.2557,1.36,1.17,1.3,1.14,0.93,0.96,0.8,0.89,48.03,6.83,52.01,7.21,101.17,10.2,105.13,10.26,3.05,1.76,3.26,1.79,1.95,2.15,0.05,0.06,0.75,0.91,63.78,54.81,110.69,106.79,3.1,3.14,33.39,37.77,2.59,2.53,0.0,0.23,1.12,1.11,1.45,1.01,,0.0907,,,0.0949,0.1233,0.0804,,0.0842,,,
70
+ 38,Tottenham Hotspur,Everton,1.26,1.26,0.3652,0.2691,0.3657,0.2848,0.2851,1.26,1.12,1.26,1.12,0.97,0.97,0.79,0.89,46.87,6.81,54.26,7.39,99.79,9.94,102.89,10.23,2.74,1.66,3.44,1.85,1.71,2.06,0.06,0.06,0.97,0.73,52.96,58.89,100.34,105.85,3.26,3.39,46.89,35.19,1.81,2.2,0.01,0.02,1.26,0.9,1.13,1.23,0.0811,0.1021,,,0.102,0.1278,0.0804,,,,,
71
+ 38,Sunderland,Chelsea,0.95,1.62,0.2216,0.2478,0.5306,0.1976,0.3859,0.95,0.98,1.62,1.27,0.92,0.95,0.9,0.95,49.14,6.96,53.0,7.26,97.97,9.92,104.07,10.18,3.18,1.77,3.09,1.76,2.08,1.98,0.05,0.06,0.85,1.07,60.41,44.2,106.56,92.94,2.06,2.86,39.44,50.18,2.65,2.14,0.06,0.11,0.92,1.04,1.69,1.18,0.0761,0.1238,0.1002,,,0.1176,0.0955,,,,,
72
+ 38,West Ham United,Leeds United,1.52,1.33,0.4177,0.2489,0.3334,0.2645,0.2193,1.52,1.23,1.33,1.15,0.98,0.98,1.06,1.05,50.92,7.21,52.72,7.19,102.67,10.1,104.07,10.13,3.03,1.75,3.13,1.75,1.89,1.92,0.06,0.06,0.81,1.39,57.65,45.49,103.85,89.82,3.7,1.72,38.28,50.45,1.98,1.44,0.01,0.0,1.2,0.95,1.26,0.98,,0.0772,,,0.0881,0.1169,0.0778,,0.0888,,,
fpl_api.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+
3
+ BASE_URL = "https://fantasy.premierleague.com/api"
4
+ AFCON_GW = 16
5
+
6
+
7
+ def calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws):
8
+ """Exact logic ported from open-fpl-solver dev/solver.py"""
9
+ n_transfers = {gw: 0 for gw in range(2, next_gw + 2)}
10
+ for t in transfers:
11
+ if t["event"] in n_transfers:
12
+ n_transfers[t["event"]] += 1
13
+
14
+ fts = {gw: 0 for gw in range(first_gw + 1, next_gw + 2)}
15
+ fts[first_gw + 1] = 1
16
+
17
+ for i in range(first_gw + 2, next_gw + 1):
18
+ if i == AFCON_GW:
19
+ fts[i] = 5
20
+ continue
21
+ if (i - 1) in fh_gws or (i - 1) in wc_gws:
22
+ fts[i] = fts[i - 1]
23
+ continue
24
+
25
+ fts[i] = fts[i - 1] - n_transfers[i - 1]
26
+ fts[i] = max(fts[i], 0)
27
+ fts[i] += 1
28
+ fts[i] = min(fts[i], 5)
29
+
30
+ return fts.get(next_gw, 1)
31
+
32
+
33
+ def get_fpl_team_data(team_id: int):
34
+ print(f"Executing strict open-fpl-solver logic for Team ID: {team_id}...")
35
+
36
+ static = requests.get(f"{BASE_URL}/bootstrap-static/").json()
37
+ element_to_type = {x["id"]: x["element_type"] for x in static["elements"]}
38
+ next_gw = next(x["id"] for x in static["events"] if x["is_next"])
39
+ start_prices = {
40
+ x["id"]: x["now_cost"] - x["cost_change_start"] for x in static["elements"]
41
+ }
42
+
43
+ transfers = requests.get(f"{BASE_URL}/entry/{team_id}/transfers/").json()[::-1]
44
+ history = requests.get(f"{BASE_URL}/entry/{team_id}/history/").json()
45
+
46
+ chips = history["chips"]
47
+ fh_gws = [x["event"] for x in chips if x["name"] == "freehit"]
48
+ wc_gws = [x["event"] for x in chips if x["name"] == "wildcard"]
49
+
50
+ first_gw = history["current"][0]["event"]
51
+ first_gw_data = requests.get(
52
+ f"{BASE_URL}/entry/{team_id}/event/{first_gw}/picks/"
53
+ ).json()
54
+
55
+ # Calculate exact purchase prices and ITB
56
+ squad = {x["element"]: start_prices[x["element"]] for x in first_gw_data["picks"]}
57
+ itb = 1000 - sum(squad.values())
58
+
59
+ for t in transfers:
60
+ if t["event"] in fh_gws:
61
+ continue
62
+ itb += t["element_out_cost"]
63
+ itb -= t["element_in_cost"]
64
+ if t["element_in"]:
65
+ squad[t["element_in"]] = t["element_in_cost"]
66
+ if t["element_out"] and t["element_out"] in squad:
67
+ del squad[t["element_out"]]
68
+
69
+ fts = calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws)
70
+
71
+ picks = []
72
+ for player_id, purchase_price in squad.items():
73
+ now_cost = next(
74
+ x["now_cost"] for x in static["elements"] if x["id"] == player_id
75
+ )
76
+ diff = now_cost - purchase_price
77
+ selling_price = purchase_price + (diff // 2) if diff > 0 else now_cost
78
+
79
+ picks.append(
80
+ {
81
+ "id": player_id,
82
+ "purchase_price": purchase_price / 10.0,
83
+ "selling_price": selling_price / 10.0,
84
+ "now_cost": now_cost / 10.0,
85
+ }
86
+ )
87
+
88
+ return {"in_the_bank": itb / 10.0, "free_transfers": fts, "squad": picks}
fpl_streamlit_app.py ADDED
The diff for this file is too large to render. See raw diff
 
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # React + Vite
2
+
3
+ This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
4
+
5
+ Currently, two official plugins are available:
6
+
7
+ - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
8
+ - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
9
+
10
+ ## React Compiler
11
+
12
+ The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
13
+
14
+ ## Expanding the ESLint configuration
15
+
16
+ If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
frontend/eslint.config.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import { defineConfig, globalIgnores } from 'eslint/config'
6
+
7
+ export default defineConfig([
8
+ globalIgnores(['dist']),
9
+ {
10
+ files: ['**/*.{js,jsx}'],
11
+ extends: [
12
+ js.configs.recommended,
13
+ reactHooks.configs.flat.recommended,
14
+ reactRefresh.configs.vite,
15
+ ],
16
+ languageOptions: {
17
+ ecmaVersion: 2020,
18
+ globals: globals.browser,
19
+ parserOptions: {
20
+ ecmaVersion: 'latest',
21
+ ecmaFeatures: { jsx: true },
22
+ sourceType: 'module',
23
+ },
24
+ },
25
+ rules: {
26
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
27
+ },
28
+ },
29
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image.png" href="/image.png" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
7
+ <title>Luigi's Mansion</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@dnd-kit/core": "^6.3.1",
14
+ "@dnd-kit/sortable": "^10.0.0",
15
+ "@dnd-kit/utilities": "^3.2.2",
16
+ "@react-oauth/google": "^0.13.4",
17
+ "@tailwindcss/postcss": "^4.2.2",
18
+ "@tanstack/react-table": "^8.21.3",
19
+ "clsx": "^2.1.1",
20
+ "framer-motion": "^12.38.0",
21
+ "lucide-react": "^1.6.0",
22
+ "react": "^19.2.4",
23
+ "react-dom": "^19.2.4",
24
+ "recharts": "^3.8.1",
25
+ "tailwind-merge": "^3.5.0"
26
+ },
27
+ "devDependencies": {
28
+ "@eslint/js": "^9.39.4",
29
+ "@types/react": "^19.2.14",
30
+ "@types/react-dom": "^19.2.3",
31
+ "@vitejs/plugin-react": "^6.0.1",
32
+ "autoprefixer": "^10.4.27",
33
+ "eslint": "^9.39.4",
34
+ "eslint-plugin-react-hooks": "^7.0.1",
35
+ "eslint-plugin-react-refresh": "^0.5.2",
36
+ "globals": "^17.4.0",
37
+ "postcss": "^8.5.8",
38
+ "tailwindcss": "^3.4.19",
39
+ "vite": "^8.0.1"
40
+ }
41
+ }
frontend/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
frontend/public/favicon.svg ADDED
frontend/public/hero.png ADDED
frontend/public/icon.jpg ADDED
frontend/public/icons.svg ADDED
frontend/public/image.png ADDED
frontend/public/l-logo.png ADDED
frontend/public/luigismansion.jpg ADDED
frontend/src/App.css ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .counter {
2
+ font-size: 16px;
3
+ padding: 5px 10px;
4
+ border-radius: 5px;
5
+ color: var(--accent);
6
+ background: var(--accent-bg);
7
+ border: 2px solid transparent;
8
+ transition: border-color 0.3s;
9
+ margin-bottom: 24px;
10
+
11
+ &:hover {
12
+ border-color: var(--accent-border);
13
+ }
14
+ &:focus-visible {
15
+ outline: 2px solid var(--accent);
16
+ outline-offset: 2px;
17
+ }
18
+ }
19
+
20
+ .hero {
21
+ position: relative;
22
+
23
+ .base,
24
+ .framework,
25
+ .vite {
26
+ inset-inline: 0;
27
+ margin: 0 auto;
28
+ }
29
+
30
+ .base {
31
+ width: 170px;
32
+ position: relative;
33
+ z-index: 0;
34
+ }
35
+
36
+ .framework,
37
+ .vite {
38
+ position: absolute;
39
+ }
40
+
41
+ .framework {
42
+ z-index: 1;
43
+ top: 34px;
44
+ height: 28px;
45
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
46
+ scale(1.4);
47
+ }
48
+
49
+ .vite {
50
+ z-index: 0;
51
+ top: 107px;
52
+ height: 26px;
53
+ width: auto;
54
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
55
+ scale(0.8);
56
+ }
57
+ }
58
+
59
+ #center {
60
+ display: flex;
61
+ flex-direction: column;
62
+ gap: 25px;
63
+ place-content: center;
64
+ place-items: center;
65
+ flex-grow: 1;
66
+
67
+ @media (max-width: 1024px) {
68
+ padding: 32px 20px 24px;
69
+ gap: 18px;
70
+ }
71
+ }
72
+
73
+ #next-steps {
74
+ display: flex;
75
+ border-top: 1px solid var(--border);
76
+ text-align: left;
77
+
78
+ & > div {
79
+ flex: 1 1 0;
80
+ padding: 32px;
81
+ @media (max-width: 1024px) {
82
+ padding: 24px 20px;
83
+ }
84
+ }
85
+
86
+ .icon {
87
+ margin-bottom: 16px;
88
+ width: 22px;
89
+ height: 22px;
90
+ }
91
+
92
+ @media (max-width: 1024px) {
93
+ flex-direction: column;
94
+ text-align: center;
95
+ }
96
+ }
97
+
98
+ #docs {
99
+ border-right: 1px solid var(--border);
100
+
101
+ @media (max-width: 1024px) {
102
+ border-right: none;
103
+ border-bottom: 1px solid var(--border);
104
+ }
105
+ }
106
+
107
+ #next-steps ul {
108
+ list-style: none;
109
+ padding: 0;
110
+ display: flex;
111
+ gap: 8px;
112
+ margin: 32px 0 0;
113
+
114
+ .logo {
115
+ height: 18px;
116
+ }
117
+
118
+ a {
119
+ color: var(--text-h);
120
+ font-size: 16px;
121
+ border-radius: 6px;
122
+ background: var(--social-bg);
123
+ display: flex;
124
+ padding: 6px 12px;
125
+ align-items: center;
126
+ gap: 8px;
127
+ text-decoration: none;
128
+ transition: box-shadow 0.3s;
129
+
130
+ &:hover {
131
+ box-shadow: var(--shadow);
132
+ }
133
+ .button-icon {
134
+ height: 18px;
135
+ width: 18px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: 1024px) {
140
+ margin-top: 20px;
141
+ flex-wrap: wrap;
142
+ justify-content: center;
143
+
144
+ li {
145
+ flex: 1 1 calc(50% - 8px);
146
+ }
147
+
148
+ a {
149
+ width: 100%;
150
+ justify-content: center;
151
+ box-sizing: border-box;
152
+ }
153
+ }
154
+ }
155
+
156
+ #spacer {
157
+ height: 88px;
158
+ border-top: 1px solid var(--border);
159
+ @media (max-width: 1024px) {
160
+ height: 48px;
161
+ }
162
+ }
163
+
164
+ .ticks {
165
+ position: relative;
166
+ width: 100%;
167
+
168
+ &::before,
169
+ &::after {
170
+ content: '';
171
+ position: absolute;
172
+ top: -4.5px;
173
+ border: 5px solid transparent;
174
+ }
175
+
176
+ &::before {
177
+ left: 0;
178
+ border-left-color: var(--border);
179
+ }
180
+ &::after {
181
+ right: 0;
182
+ border-right-color: var(--border);
183
+ }
184
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,280 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useContext, useEffect } from "react";
2
+ import { motion, AnimatePresence } from "framer-motion";
3
+ import {
4
+ Activity,
5
+ BarChart2,
6
+ Shield,
7
+ Calendar,
8
+ Zap,
9
+ LogIn,
10
+ Settings,
11
+ X,
12
+ } from "lucide-react";
13
+
14
+ import { LandingPage } from "./components/LandingPage";
15
+ import ProjectionsTable from "./components/ProjectionsTable";
16
+ import AccuracyDashboard from "./components/AccuracyDashboard";
17
+ import TeamRatings from "./components/TeamRatings";
18
+ import Fixtures from "./components/Fixtures";
19
+ import Solver from "./components/Solver";
20
+ import LoginModal from "./components/LoginModal";
21
+ import { PlayerProvider, PlayerContext } from "./PlayerContext";
22
+
23
+
24
+ const tabs = [
25
+ { id: "solver", label: "Solver", icon: Zap },
26
+ { id: "projections", label: "Projections", icon: Activity },
27
+ { id: "accuracy", label: "Accuracy", icon: BarChart2 },
28
+ { id: "ratings", label: "Team Ratings", icon: Shield },
29
+ { id: "fixtures", label: "Fixtures", icon: Calendar },
30
+ ];
31
+
32
+ function AppContent() {
33
+ const [activeTab, setActiveTab] = useState(tabs[0].id);
34
+ const [showLoginModal, setShowLoginModal] = useState(false);
35
+ const [showSettings, setShowSettings] = useState(false);
36
+
37
+ const {
38
+ isLoggedIn,
39
+ setIsLoggedIn,
40
+ userProfile,
41
+ setUserProfile,
42
+ hasGuestMadeEdits,
43
+ setHasGuestMadeEdits,
44
+ isCheckingAuth, // <-- Pull in the new state
45
+ } = useContext(PlayerContext);
46
+
47
+ const [newDefaultId, setNewDefaultId] = useState("");
48
+ const [isSaved, setIsSaved] = useState(false);
49
+
50
+ // Sync local input with context profile
51
+ useEffect(() => {
52
+ if (userProfile?.defaultTeamId) {
53
+ setNewDefaultId(userProfile.defaultTeamId);
54
+ }
55
+ }, [userProfile]);
56
+
57
+ const handleUpdateDefaultId = () => {
58
+ const parsedId = parseInt(newDefaultId);
59
+ if (!parsedId) return;
60
+
61
+ const token = localStorage.getItem('fpl_token');
62
+ if (token) {
63
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
66
+ body: JSON.stringify({ default_team_id: parsedId })
67
+ }).then(() => {
68
+ setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId }));
69
+ setIsSaved(true);
70
+ setTimeout(() => setIsSaved(false), 2000);
71
+ });
72
+ }
73
+ };
74
+
75
+ // --- THE NEW LOADING INTERCEPTOR ---
76
+ // If we are actively checking the token, show a sleek loading screen instead of the landing page
77
+ if (isCheckingAuth) {
78
+ return (
79
+ <div className="min-h-screen bg-slate-950 flex flex-col items-center justify-center">
80
+ <div className="w-12 h-12 border-4 border-slate-800 border-t-luigi-500 rounded-full animate-spin"></div>
81
+ <p className="mt-4 text-luigi-400 font-bold tracking-widest uppercase text-xs animate-pulse">Entering Mansion...</p>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // If we finished checking and they definitely aren't logged in, show the gatekeeper
87
+ if (!isLoggedIn) {
88
+ return <LandingPage />;
89
+ }
90
+
91
+ const handleLogout = () => {
92
+ localStorage.removeItem("fpl_token");
93
+ setIsLoggedIn(false);
94
+ setUserProfile({ username: "Guest", defaultTeamId: null, isAdmin: false });
95
+ setShowSettings(false);
96
+ setActiveTab("solver"); // <-- Forces the app back to the Team ID loading page!
97
+ };
98
+
99
+ return (
100
+ <div className="min-h-screen bg-slate-950 text-slate-200 font-sans selection:bg-luigi-500/30">
101
+ <header className="border-b border-slate-800 bg-slate-950/80 backdrop-blur-md sticky top-0 z-50 shadow-sm">
102
+ <div className="max-w-[1600px] w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 flex flex-col md:flex-row md:items-center justify-between gap-4">
103
+ {/* THE RESTORED TITLE */}
104
+ <div
105
+ onClick={() => setActiveTab("solver")}
106
+ className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
107
+ >
108
+ <img
109
+ src="/l-logo.png"
110
+ alt="Luigi's Mansion Logo"
111
+ className="w-8 h-8 object-contain drop-shadow-[0_0_12px_rgba(16,185,129,0.5)]"
112
+ />
113
+ <h1 className="text-2xl font-black text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-cyan-500 tracking-tight whitespace-nowrap">
114
+ Luigi's Mansion
115
+ </h1>
116
+ </div>
117
+
118
+ <nav className="flex space-x-1 overflow-x-auto pb-2 md:pb-0 hide-scrollbar">
119
+ {tabs.map((tab) => {
120
+ const Icon = tab.icon;
121
+ return (
122
+ <button
123
+ key={tab.id}
124
+ onClick={() => setActiveTab(tab.id)}
125
+ className={`flex items-center px-4 py-2.5 rounded-lg text-sm font-semibold whitespace-nowrap transition-all duration-200 ease-in-out ${activeTab === tab.id ? "bg-luigi-500/10 text-luigi-400 shadow-[inset_0_-2px_0_rgba(16,185,129,1)]" : "text-slate-400 hover:text-slate-200 hover:bg-slate-800/50"}`}
126
+ >
127
+ <Icon className="w-4 h-4 mr-2" />
128
+ {tab.label}
129
+ </button>
130
+ );
131
+ })}
132
+ </nav>
133
+
134
+ <div className="flex items-center gap-4 mt-4 md:mt-0 relative">
135
+ {isLoggedIn ? (
136
+ <div className="flex items-center gap-3 relative">
137
+ <div className="flex flex-col text-right">
138
+ <div className="flex items-center gap-1.5 justify-end">
139
+ {userProfile.isAdmin && (
140
+ <Shield
141
+ size={14}
142
+ className="text-yellow-500"
143
+ title="Admin Mode"
144
+ />
145
+ )}
146
+ <span className="text-sm font-bold text-slate-200">
147
+ {userProfile.username}
148
+ </span>
149
+ </div>
150
+ </div>
151
+
152
+ <button
153
+ onClick={() => setShowSettings(!showSettings)}
154
+ className="p-2 bg-slate-900 border border-slate-700 rounded-full hover:bg-slate-800 hover:border-luigi-500 hover:text-luigi-400 transition-colors shadow-sm"
155
+ >
156
+ <Settings size={18} />
157
+ </button>
158
+
159
+ {showSettings && (
160
+ <div className="absolute top-full right-0 mt-2 w-72 bg-slate-900 border border-slate-700 rounded-xl shadow-2xl p-4 z-50 animate-in fade-in slide-in-from-top-2 flex flex-col gap-4">
161
+
162
+ {/* Default ID Setting */}
163
+ <div>
164
+ <h4 className="text-[10px] font-black text-slate-500 uppercase tracking-wider mb-2">
165
+ Default FPL ID
166
+ </h4>
167
+ <div className="flex items-center gap-2">
168
+ <input
169
+ type="number"
170
+ value={newDefaultId}
171
+ onChange={(e) => setNewDefaultId(e.target.value)}
172
+ placeholder="e.g. 123456"
173
+ className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-xs font-bold text-slate-200 outline-none focus:border-luigi-500 flex-1 shadow-inner"
174
+ />
175
+ <button
176
+ onClick={handleUpdateDefaultId}
177
+ className={`px-3 py-1.5 rounded text-xs font-bold transition-all shadow-md ${isSaved
178
+ ? 'bg-luigi-500 text-slate-950'
179
+ : 'bg-slate-800 hover:bg-slate-700 border border-slate-600 text-white active:scale-95'
180
+ }`}
181
+ >
182
+ {isSaved ? 'Saved ✓' : 'Save'}
183
+ </button>
184
+ </div>
185
+ </div>
186
+
187
+ <div className="h-px w-full bg-slate-800"></div>
188
+
189
+ <button
190
+ onClick={handleLogout}
191
+ className="w-full text-left px-3 py-2 text-sm font-bold text-red-400 hover:bg-slate-800 hover:text-red-300 rounded-lg transition-colors flex items-center justify-between"
192
+ >
193
+ <span>Log Out</span>
194
+ <LogIn size={14} className="rotate-180 opacity-50" />
195
+ </button>
196
+ </div>
197
+ )}
198
+ </div>
199
+ ) : (
200
+ <div className="relative">
201
+ <button
202
+ onClick={() => setShowLoginModal(true)}
203
+ className="flex items-center gap-2 px-4 py-2 bg-slate-900 border border-slate-700 hover:border-luigi-500 rounded-lg text-sm font-bold text-slate-300 hover:text-luigi-400 transition-colors shadow-sm"
204
+ >
205
+ <LogIn size={16} /> Log In
206
+ </button>
207
+
208
+ {hasGuestMadeEdits && (
209
+ <div className="absolute top-full right-0 mt-3 w-64 bg-slate-900 border border-luigi-500/50 shadow-[0_0_20px_rgba(16,185,129,0.15)] rounded-xl p-4 animate-in fade-in slide-in-from-top-4 z-50">
210
+ <button
211
+ onClick={() => setHasGuestMadeEdits(false)}
212
+ className="absolute top-2 right-2 text-slate-500 hover:text-slate-300 transition-colors"
213
+ >
214
+ <X size={14} />
215
+ </button>
216
+ <div className="flex items-start gap-3">
217
+ <div className="text-xl">💡</div>
218
+ <p className="text-xs font-medium text-slate-300 leading-tight">
219
+ You've made custom adjustments!{" "}
220
+ <span
221
+ className="text-luigi-400 font-bold cursor-pointer hover:underline"
222
+ onClick={() => setShowLoginModal(true)}
223
+ >
224
+ Log in
225
+ </span>{" "}
226
+ to save your session.
227
+ </p>
228
+ </div>
229
+ <div className="absolute -top-2 right-6 w-4 h-4 bg-slate-900 border-t border-l border-luigi-500/50 transform rotate-45"></div>
230
+ </div>
231
+ )}
232
+ </div>
233
+ )}
234
+ </div>
235
+ </div>
236
+ </header>
237
+
238
+ <main className="max-w-[1600px] w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
239
+ {/* Solver is always mounted so local state (snapshot, pairs, highlights) survives tab switches */}
240
+ <div
241
+ className={activeTab !== "solver" ? "hidden" : ""}
242
+ aria-hidden={activeTab !== "solver"}
243
+ >
244
+ <Solver />
245
+ </div>
246
+
247
+ {/* Every other tab is animated and only rendered when active */}
248
+ {activeTab !== "solver" && (
249
+ <AnimatePresence mode="wait">
250
+ <motion.div
251
+ key={activeTab}
252
+ initial={{ opacity: 0, y: 10 }}
253
+ animate={{ opacity: 1, y: 0 }}
254
+ exit={{ opacity: 0, y: -10 }}
255
+ transition={{ duration: 0.2 }}
256
+ >
257
+ {activeTab === "projections" && <ProjectionsTable />}
258
+ {activeTab === "accuracy" && <AccuracyDashboard />}
259
+ {activeTab === "ratings" && <TeamRatings />}
260
+ {activeTab === "fixtures" && <Fixtures />}
261
+ </motion.div>
262
+ </AnimatePresence>
263
+ )}
264
+ </main>
265
+
266
+ <LoginModal
267
+ isOpen={showLoginModal}
268
+ onClose={() => setShowLoginModal(false)}
269
+ />
270
+ </div>
271
+ );
272
+ }
273
+
274
+ export default function App() {
275
+ return (
276
+ <PlayerProvider>
277
+ <AppContent />
278
+ </PlayerProvider>
279
+ );
280
+ }
frontend/src/PlayerContext.jsx ADDED
@@ -0,0 +1,572 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/PlayerContext.jsx
2
+ import React, { createContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
3
+
4
+ export const PlayerContext = createContext();
5
+
6
+ export const PlayerProvider = ({ children }) => {
7
+ const globalFixturesRef = useRef({});
8
+ const [originalPlayers, setOriginalPlayers] = useState([]);
9
+ const [globalPlayers, setGlobalPlayers] = useState([]);
10
+ const [globalFixtures, setGlobalFixtures] = useState({});
11
+ const [isLoadingDB, setIsLoadingDB] = useState(true);
12
+
13
+ const [teamId, setTeamId] = useState('');
14
+ const [availableGWs, setAvailableGWs] = useState([]);
15
+ const [itb, setItb] = useState(0);
16
+ const [availableFts, setAvailableFts] = useState(1);
17
+ const [initialSquadIds, setInitialSquadIds] = useState([]);
18
+ const [solverResult, setSolverResult] = useState(null);
19
+ const [activeChip, setActiveChip] = useState(null);
20
+ const [solveElapsedSec, setSolveElapsedSec] = useState(0);
21
+ const [numSims, setNumSims] = useState(100);
22
+ const HIT_COST = 4;
23
+
24
+ const [baselineItb, setBaselineItb] = useState(0);
25
+ const [baselineFt, setBaselineFt] = useState(1);
26
+ const [comprehensiveSettings, setComprehensiveSettings] = useState({});
27
+ const [globalXmins, setGlobalXmins] = useState({});
28
+
29
+
30
+ const [quickSettings, setQuickSettings] = useState({
31
+ decay: 0.85,
32
+ ft_value: 1.5,
33
+ iterations: 1,
34
+ banned: [],
35
+ locked: [],
36
+ });
37
+
38
+ const [advancedSettings, setAdvancedSettings] = useState({
39
+ hit_cost: 4,
40
+ itb_value: 0.08,
41
+ max_per_team: 3,
42
+ vice_weight: 0.05,
43
+ time_limit_sec: 30,
44
+ no_transfer_last_gws: 0
45
+ });
46
+
47
+ // FIXED: FPL 24/25 FT Rollover Logic
48
+ const ftAtStartOfGw = (targetGw, gwsArray, baseFt, trByGw, chByGw) => {
49
+ if (!gwsArray || !gwsArray.length) return baseFt;
50
+ let ft = baseFt;
51
+ for (let gw = gwsArray[0]; gw < targetGw; gw++) {
52
+ const chip = chByGw[gw];
53
+ if (chip === 'wc' || chip === 'fh') {
54
+ ft = Math.min(5, ft);
55
+ } else {
56
+ const used = trByGw[gw]?.count || 0;
57
+ ft = Math.min(5, Math.max(0, ft - used) + 1);
58
+ }
59
+ }
60
+ return Math.max(1, Math.min(5, ft));
61
+ };
62
+
63
+ const itbAtStartOfGw = (targetGw, gwsArray, baseItb, trByGw) => {
64
+ let currentItb = baseItb;
65
+ if (!gwsArray || !gwsArray.length) return currentItb;
66
+ for (let gw = gwsArray[0]; gw < targetGw; gw++) {
67
+ currentItb += (trByGw[gw]?.netDelta || 0);
68
+ }
69
+ return currentItb;
70
+ };
71
+
72
+ // =========================================================
73
+ // --- MULTIVERSE DRAFTS ENGINE (Phase 1) ---
74
+ // =========================================================
75
+ const [drafts, setDrafts] = useState([{
76
+ id: "main_1",
77
+ name: "Main Timeline",
78
+ teamData: [],
79
+ horizon: 5,
80
+ activeGW: null,
81
+ captainId: null,
82
+ viceId: null,
83
+ solverTransferPairs: {},
84
+ solverApplySnapshot: null,
85
+ appliedPlanSummary: null,
86
+ hitsThisGw: 0,
87
+ highlightTransferIds: {},
88
+ transfersByGw: {},
89
+ chipsByGw: {},
90
+ manualOverrides: {},
91
+ fixtureOverrides: {}, // <-- ADDED: Isolated Fixtures
92
+ sessionEdits: {} // <-- ADDED: Isolated Minutes
93
+ }]);
94
+
95
+ const [activeDraftId, setActiveDraftId] = useState("main_1");
96
+
97
+ // 1. EXTRACT CURRENT REALITY
98
+ const activeDraft = drafts.find(d => d.id === activeDraftId) || drafts[0];
99
+
100
+ const teamData = activeDraft.teamData;
101
+ const horizon = activeDraft.horizon;
102
+ const activeGW = activeDraft.activeGW;
103
+ const captainId = activeDraft.captainId;
104
+ const viceId = activeDraft.viceId;
105
+ const solverTransferPairs = activeDraft.solverTransferPairs || {};
106
+ const solverApplySnapshot = activeDraft.solverApplySnapshot;
107
+ const appliedPlanSummary = activeDraft.appliedPlanSummary;
108
+ const hitsThisGw = activeDraft.hitsThisGw;
109
+ const highlightTransferIds = activeDraft.highlightTransferIds || {};
110
+ const transfersByGw = activeDraft.transfersByGw || {};
111
+ const chipsByGw = activeDraft.chipsByGw || {};
112
+
113
+ // Safe extraction fallbacks for older local cache hits
114
+ const manualOverrides = activeDraft.manualOverrides || {};
115
+ const fixtureOverrides = activeDraft.fixtureOverrides || {};
116
+ const sessionEdits = activeDraft.sessionEdits || {};
117
+
118
+ const effectiveFixtures = useMemo(() => {
119
+ return { ...globalFixtures, ...(fixtureOverrides || {}) };
120
+ }, [globalFixtures, fixtureOverrides]);
121
+
122
+ // 2. PROXY SETTERS (Intercepts state calls and routes them to the active draft)
123
+ const updateDraftState = useCallback((key, newValue) => {
124
+ setDrafts(prevDrafts => {
125
+ const activeIndex = prevDrafts.findIndex(d => d.id === activeDraftId);
126
+ if (activeIndex === -1) return prevDrafts;
127
+
128
+ const draft = prevDrafts[activeIndex];
129
+ // Provide an empty object fallback for expected object states to prevent functional crashes
130
+ const currentValue = draft[key] !== undefined ? draft[key] : (key === 'teamData' || key === 'availableGWs' ? [] : {});
131
+ const evaluatedValue = typeof newValue === 'function' ? newValue(currentValue) : newValue;
132
+
133
+ const newDrafts = [...prevDrafts];
134
+ newDrafts[activeIndex] = { ...draft, [key]: evaluatedValue };
135
+ return newDrafts;
136
+ });
137
+ }, [activeDraftId]);
138
+
139
+ const setTeamData = useCallback((val) => updateDraftState("teamData", val), [updateDraftState]);
140
+ const setHorizon = useCallback((val) => updateDraftState("horizon", val), [updateDraftState]);
141
+ const setActiveGW = useCallback((val) => updateDraftState("activeGW", val), [updateDraftState]);
142
+ const setCaptainId = useCallback((val) => updateDraftState("captainId", val), [updateDraftState]);
143
+ const setViceId = useCallback((val) => updateDraftState("viceId", val), [updateDraftState]);
144
+ const setSolverTransferPairs = useCallback((val) => updateDraftState("solverTransferPairs", val), [updateDraftState]);
145
+ const setSolverApplySnapshot = useCallback((val) => updateDraftState("solverApplySnapshot", val), [updateDraftState]);
146
+ const setAppliedPlanSummary = useCallback((val) => updateDraftState("appliedPlanSummary", val), [updateDraftState]);
147
+ const setHitsThisGw = useCallback((val) => updateDraftState("hitsThisGw", val), [updateDraftState]);
148
+ const setHighlightTransferIds = useCallback((val) => updateDraftState("highlightTransferIds", val), [updateDraftState]);
149
+ const setTransfersByGw = useCallback((val) => updateDraftState("transfersByGw", val), [updateDraftState]);
150
+ const setChipsByGw = useCallback((val) => updateDraftState("chipsByGw", val), [updateDraftState]);
151
+ const setFixtureOverrides = useCallback((val) => updateDraftState("fixtureOverrides", val), [updateDraftState]); // <-- GHOST PATCH
152
+ const setSessionEdits = useCallback((val) => updateDraftState("sessionEdits", val), [updateDraftState]); // <-- GHOST PATCH
153
+ // =========================================================
154
+
155
+ const manualOverridesRef = useRef(manualOverrides);
156
+ useEffect(() => { manualOverridesRef.current = manualOverrides; }, [manualOverrides]);
157
+
158
+ const [projSearchTerm, setProjSearchTerm] = useState('');
159
+ const sessionEditsRef = useRef(sessionEdits);
160
+ useEffect(() => { sessionEditsRef.current = sessionEdits; }, [sessionEdits]);
161
+
162
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
163
+ const [isCheckingAuth, setIsCheckingAuth] = useState(true);
164
+ const [userProfile, setUserProfile] = useState({ username: "Guest", defaultTeamId: null, isAdmin: false });
165
+ const [hasGuestMadeEdits, setHasGuestMadeEdits] = useState(false);
166
+ const [pendingWorkspaceLoad, setPendingWorkspaceLoad] = useState(null);
167
+
168
+ // Custom proxy setter for manualOverrides to retain your exact Auth saving logic
169
+ const setManualOverrides = useCallback((updater) => {
170
+ updateDraftState("manualOverrides", (prev) => {
171
+ const next = typeof updater === 'function' ? updater(prev) : updater;
172
+ const token = localStorage.getItem('fpl_token');
173
+ if (token) {
174
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
177
+ body: JSON.stringify({ saved_edits: { ...sessionEditsRef.current, _solver_overrides: next } })
178
+ });
179
+ }
180
+ return next;
181
+ });
182
+ }, [updateDraftState]);
183
+
184
+ const saveSession = (overrides) => {
185
+ const token = localStorage.getItem('fpl_token');
186
+ if (token) {
187
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
188
+ method: 'POST',
189
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
190
+ body: JSON.stringify({
191
+ saved_edits: { ...sessionEditsRef.current, _solver_overrides: overrides },
192
+ drafts: drafts
193
+ })
194
+ });
195
+ }
196
+ };
197
+
198
+ useEffect(() => {
199
+ // YOUR EXACT PROJECTIONS API ENDPOINT RESTORED
200
+ fetch('https://anayshukla-fpl-solver.hf.space/api/projections')
201
+ .then(res => { if (!res.ok) throw new Error("DB Error"); return res.json(); })
202
+ .then(data => {
203
+ setOriginalPlayers(JSON.parse(JSON.stringify(data)));
204
+ setGlobalPlayers(data);
205
+ setIsLoadingDB(false);
206
+ })
207
+ .catch(err => setIsLoadingDB(false));
208
+
209
+ // THE FIX: Store global fixtures in the Ref so the Auth wipe doesn't delete them!
210
+ fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/overrides')
211
+ .then(res => res.ok ? res.json() : {})
212
+ .then(data => {
213
+ if (Object.keys(data).length > 0) setGlobalFixtures(data);
214
+ })
215
+ .catch(err => console.error("Failed to load global fixtures:", err));
216
+
217
+ fetch('https://anayshukla-fpl-solver.hf.space/api/xmins/overrides')
218
+ .then(res => res.ok ? res.json() : {})
219
+ .then(data => { if (Object.keys(data).length > 0) setGlobalXmins(data); })
220
+ .catch(err => console.error("Failed to load global xMins:", err));
221
+ }, []);
222
+
223
+ useEffect(() => {
224
+ const token = localStorage.getItem('fpl_token');
225
+ if (token) {
226
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
227
+ .then(res => res.json())
228
+ .then(data => {
229
+ if (data.email) {
230
+ setUserProfile({ username: data.email.split('@')[0], defaultTeamId: data.default_team_id, isAdmin: data.is_admin });
231
+
232
+ // THE FIX: Inject the Multiverse realities from the database!
233
+ if (data.drafts && data.drafts.length > 0) {
234
+ setDrafts(data.drafts);
235
+ const saved = data.saved_edits || {};
236
+ if (saved._active_draft_id && data.drafts.some(d => d.id === saved._active_draft_id)) {
237
+ setActiveDraftId(saved._active_draft_id);
238
+ } else {
239
+ setActiveDraftId(data.drafts[0].id);
240
+ }
241
+ }
242
+
243
+ const saved = data.saved_edits || {};
244
+ if (saved._solver_overrides) {
245
+ setManualOverrides(saved._solver_overrides);
246
+ delete saved._solver_overrides;
247
+ }
248
+ if (saved._workspace) {
249
+ setPendingWorkspaceLoad(saved._workspace);
250
+ delete saved._workspace;
251
+ }
252
+ setSessionEdits(saved);
253
+ setIsLoggedIn(true);
254
+ if (data.default_team_id) setTeamId(String(data.default_team_id));
255
+ } else {
256
+ localStorage.removeItem('fpl_token');
257
+ setSessionEdits({});
258
+ setManualOverrides({});
259
+ setTeamId('');
260
+ setTeamData([]);
261
+ }
262
+ }).catch(() => {
263
+ localStorage.removeItem('fpl_token');
264
+ setSessionEdits({});
265
+ setManualOverrides({});
266
+ setTeamId('');
267
+ setTeamData([]);
268
+ }).finally(() => {
269
+ setIsCheckingAuth(false);
270
+ });
271
+ } else {
272
+ setSessionEdits({});
273
+ setManualOverrides({});
274
+ setTeamId('');
275
+ setTeamData([]);
276
+ setIsCheckingAuth(false);
277
+ }
278
+ }, [isLoggedIn]);
279
+
280
+ useEffect(() => {
281
+ if (originalPlayers.length > 0) {
282
+ setGlobalPlayers(prev => {
283
+ const newPlayers = JSON.parse(JSON.stringify(originalPlayers));
284
+
285
+ // --- 1. THE STOCHASTIC FIXTURE ENGINE ---
286
+ newPlayers.forEach(p => {
287
+ if (p.match_projections) {
288
+ // A. Zero out old stats + Track probability sum for averaging
289
+ const gwKeys = Object.keys(p).filter(k => k.includes('_Pts')).map(k => k.split('_')[0]);
290
+ gwKeys.forEach(gw => {
291
+ p[`${gw}_Pts`] = 0; p[`${gw}_xMins`] = 0; p[`${gw}_xG`] = 0; p[`${gw}_xA`] = 0; p[`${gw}_CS`] = 0;
292
+ p[`${gw}_probSum`] = 0; // NEW: Tracks total matches in this GW
293
+ });
294
+
295
+ // B. Loop matches and apply Match-Level Minute Edits
296
+ const manualBaseline = sessionEdits[p.ID]?.baseline_xMins;
297
+ const origBaseline = p.baseline_xMins || 90;
298
+ const baselineScale = (manualBaseline != null && origBaseline > 0) ? (Number(manualBaseline) / origBaseline) : 1.0;
299
+
300
+ Object.entries(p.match_projections).forEach(([matchId, mData]) => {
301
+ const override = effectiveFixtures[matchId];;
302
+
303
+ let manualMins = sessionEdits[p.ID]?.[`${matchId}_xMins`];
304
+ let globalMatchMins = globalXmins[p.ID]?.[matchId];
305
+
306
+ if (manualMins === undefined) {
307
+ if (globalMatchMins !== undefined) {
308
+ manualMins = globalMatchMins;
309
+ } else {
310
+ let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw;
311
+ if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw];
312
+ }
313
+ }
314
+
315
+ // THE FIX: Apply match edit, OR dynamically scale by the baseline edit!
316
+ const activeMins = manualMins != null
317
+ ? Number(manualMins)
318
+ : Math.min((mData.xMins * baselineScale), 90);
319
+
320
+ // Scale the match EV based on the active minutes
321
+ const scaling = (activeMins > 0 && mData.xMins > 0) ? (activeMins / mData.xMins) : 0;
322
+ const aPts = mData.Pts * scaling;
323
+ const axG = mData.xG * scaling;
324
+ const axA = mData.xA * scaling;
325
+ const aCS = mData.CS * scaling;
326
+
327
+ // C. Distribute the scaled EV
328
+ if (override) {
329
+ Object.entries(override).forEach(([gwStr, prob]) => {
330
+ const gw = gwStr;
331
+ p[`${gw}_Pts`] += (aPts * prob);
332
+ p[`${gw}_xMins`] += (activeMins * prob);
333
+ p[`${gw}_probSum`] += prob; // Add probability to the GW sum
334
+ p[`${gw}_xG`] += (axG * prob); p[`${gw}_xA`] += (axA * prob); p[`${gw}_CS`] += (aCS * prob);
335
+ });
336
+ } else {
337
+ const gw = mData.default_gw;
338
+ p[`${gw}_Pts`] += aPts;
339
+ p[`${gw}_xMins`] += activeMins;
340
+ p[`${gw}_probSum`] += 1.0;
341
+ p[`${gw}_xG`] += axG; p[`${gw}_xA`] += axA; p[`${gw}_CS`] += aCS;
342
+ }
343
+ });
344
+
345
+ // D. Calculate FPL Average xMins
346
+ gwKeys.forEach(gw => {
347
+ if (p[`${gw}_probSum`] > 0) {
348
+ p[`${gw}_xMins`] = Math.round(p[`${gw}_xMins`] / p[`${gw}_probSum`]);
349
+ }
350
+ });
351
+ }
352
+ });
353
+
354
+ // --- 2. APPLY MANUAL SESSION EDITS (Overwrites everything) ---
355
+ if (Object.keys(sessionEdits).length > 0) {
356
+ Object.keys(sessionEdits).forEach(playerId => {
357
+ if (playerId === '_solver_overrides') return;
358
+
359
+ const pid = parseInt(playerId);
360
+ const pIdx = newPlayers.findIndex(p => p.ID === pid);
361
+ if (pIdx > -1) {
362
+ const edits = sessionEdits[playerId];
363
+ Object.keys(edits).forEach(editKey => {
364
+ newPlayers[pIdx][editKey] = edits[editKey];
365
+ if (editKey.includes('_xMins') && !editKey.includes('_vs_')) {
366
+ const gw = editKey.split('_')[0];
367
+ if (edits[`${gw}_Pts`] === undefined) {
368
+ const baseMins = originalPlayers[pIdx][`${gw}_xMins`] || 90;
369
+ const basePts = originalPlayers[pIdx][`${gw}_Pts`] || 0;
370
+ newPlayers[pIdx][`${gw}_Pts`] = baseMins > 0 ? (basePts / baseMins) * edits[editKey] : 0;
371
+ }
372
+ }
373
+ });
374
+ }
375
+ });
376
+ }
377
+
378
+ return newPlayers;
379
+ });
380
+ }
381
+ }, [originalPlayers, isLoggedIn, sessionEdits, effectiveFixtures]);
382
+
383
+ useEffect(() => {
384
+ if (!isLoggedIn || isLoadingDB || globalPlayers.length === 0) return;
385
+
386
+ const timeout = setTimeout(() => {
387
+ const workspace = {
388
+ teamData: teamData.map(p => ({ ID: p.ID, Price: p.Price, isBlank: p.isBlank, replacedPlayer: p.replacedPlayer })),
389
+ horizon,
390
+ activeGW,
391
+ baselineItb,
392
+ baselineFt,
393
+ transfersByGw,
394
+ chipsByGw,
395
+ quickSettings,
396
+ advancedSettings,
397
+ highlightTransferIds: Object.fromEntries(Object.entries(highlightTransferIds).map(([k,v]) => [k, Array.from(v)])),
398
+ solverTransferPairs
399
+ };
400
+
401
+ const token = localStorage.getItem('fpl_token');
402
+ if (token) {
403
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
404
+ method: 'POST',
405
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
406
+ body: JSON.stringify({
407
+ saved_edits: {
408
+ ...sessionEditsRef.current,
409
+ _solver_overrides: manualOverridesRef.current,
410
+ _workspace: workspace,
411
+ _active_draft_id: activeDraftId
412
+ },
413
+ drafts: drafts // <-- THE FIX: Package and send the entire Multiverse!
414
+ })
415
+ });
416
+ }
417
+ }, 500);
418
+ // THE FIX: Added 'drafts' to the end of this dependency array so edits trigger the save
419
+ }, [teamData, horizon, activeGW, baselineItb, baselineFt, transfersByGw, chipsByGw, quickSettings, advancedSettings, highlightTransferIds, solverTransferPairs, isLoggedIn, isLoadingDB, globalPlayers, drafts]);
420
+
421
+ useEffect(() => {
422
+ if (globalPlayers.length === 0 || teamData.length === 0) return;
423
+
424
+ setTeamData(prevTeam => {
425
+ let needsUpdate = false;
426
+
427
+ const syncedTeam = prevTeam.map(tp => {
428
+ const gp = globalPlayers.find(p => p.ID === tp.ID);
429
+ if (!gp) return tp;
430
+
431
+ // If the squad's math is out of sync with the global math, flag it for an update!
432
+ // (We check a few sample gameweeks to guarantee we catch the mismatch)
433
+ if (
434
+ tp['1_Pts'] !== gp['1_Pts'] ||
435
+ tp['19_Pts'] !== gp['19_Pts'] ||
436
+ tp['38_Pts'] !== gp['38_Pts'] ||
437
+ tp.baseline_xMins !== gp.baseline_xMins
438
+ ) {
439
+ needsUpdate = true;
440
+ return { ...tp, ...gp, Price: tp.Price }; // Merges the math, but protects your specific Selling Price!
441
+ }
442
+ return tp;
443
+ });
444
+
445
+ // Only triggers a re-render if it actually found stale data
446
+ return needsUpdate ? syncedTeam : prevTeam;
447
+ });
448
+ }, [globalPlayers, teamData, setTeamData]);
449
+
450
+ const updatePlayerStat = (playerId, gw, statKey, rawValue) => {
451
+ let finalValue = statKey === 'xMins' ? Math.min(Math.max(Number(rawValue), 0), 90) : rawValue;
452
+ if (!isLoggedIn) setHasGuestMadeEdits(true);
453
+
454
+ const pristinePlayer = originalPlayers.find(p => p.ID === playerId);
455
+ let calculatedPts = 0;
456
+
457
+ // 1. Determine the exact original value to see if we are reverting
458
+ let isRevertingToOriginal = false;
459
+ const isMatchId = String(gw).includes('_vs_');
460
+
461
+ // FIX: Look specifically inside match_projections for DGW match edits!
462
+ if (pristinePlayer && pristinePlayer.match_projections && isMatchId) {
463
+ const mData = pristinePlayer.match_projections[gw];
464
+ if (mData) {
465
+ const mOrig = statKey === 'xMins' ? mData.xMins : mData[statKey];
466
+ isRevertingToOriginal = (finalValue === mOrig);
467
+ }
468
+ } else {
469
+ const originalValue = pristinePlayer ? pristinePlayer[`${gw}_${statKey}`] : undefined;
470
+ if (originalValue !== undefined) {
471
+ isRevertingToOriginal = (finalValue === originalValue);
472
+ } else if (statKey === 'xMins' && finalValue === 90) {
473
+ isRevertingToOriginal = true;
474
+ }
475
+ }
476
+
477
+ // Calculate instant EV for UI feedback
478
+ if (statKey === 'xMins') {
479
+ if (pristinePlayer && pristinePlayer.match_projections && isMatchId) {
480
+ const mData = pristinePlayer.match_projections[gw];
481
+ const baseMins = mData ? mData.xMins : 90;
482
+ const basePts = mData ? mData.Pts : 0;
483
+ calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0;
484
+ } else {
485
+ const baseMins = pristinePlayer ? pristinePlayer[`${gw}_xMins`] || 90 : 90;
486
+ const basePts = pristinePlayer ? pristinePlayer[`${gw}_Pts`] || 0 : 0;
487
+ calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0;
488
+ }
489
+ }
490
+
491
+ setGlobalPlayers(prev => prev.map(p => {
492
+ if (p.ID === playerId) {
493
+ let updated = { ...p, [`${gw}_${statKey}`]: finalValue };
494
+ if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts;
495
+ return updated;
496
+ }
497
+ return p;
498
+ }));
499
+
500
+ setTeamData(prev => prev.map(p => {
501
+ if (p.ID === playerId) {
502
+ let updated = { ...p, [`${gw}_${statKey}`]: finalValue };
503
+ if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts;
504
+ return updated;
505
+ }
506
+ return p;
507
+ }));
508
+
509
+ // 2. The Smart Self-Cleaning Session Edits
510
+ setSessionEdits(prev => {
511
+ const newEdits = { ...prev };
512
+
513
+ if (isRevertingToOriginal) {
514
+ if (newEdits[playerId]) {
515
+ newEdits[playerId] = { ...newEdits[playerId] };
516
+ delete newEdits[playerId][`${gw}_${statKey}`];
517
+ if (statKey === 'xMins') delete newEdits[playerId][`${gw}_Pts`];
518
+
519
+ if (Object.keys(newEdits[playerId]).length === 0) delete newEdits[playerId];
520
+ }
521
+ } else {
522
+ if (!newEdits[playerId]) newEdits[playerId] = {};
523
+ newEdits[playerId] = { ...newEdits[playerId], [`${gw}_${statKey}`]: finalValue };
524
+
525
+ if (statKey === 'xMins') {
526
+ // Do not hardcode match points so the stochastic engine can dynamically scale it!
527
+ if (!isMatchId) newEdits[playerId][`${gw}_Pts`] = calculatedPts;
528
+ }
529
+ }
530
+
531
+ if (isLoggedIn) {
532
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
533
+ method: 'POST',
534
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('fpl_token')}` },
535
+ body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverridesRef.current } })
536
+ });
537
+ }
538
+ return newEdits;
539
+ });
540
+ };
541
+
542
+ // --- STOCHASTIC ENGINE INVALIDATION ---
543
+ // If the user changes any fixture odds, the current solver lineup is now mathematically invalid.
544
+ // This instantly clears the stale lineup and forces the UI to reset.
545
+ useEffect(() => {
546
+ if (solverResult) {
547
+ setSolverResult(null);
548
+ }
549
+ if (appliedPlanSummary) {
550
+ setAppliedPlanSummary(null);
551
+ }
552
+ }, [fixtureOverrides]);
553
+
554
+ return (
555
+ <PlayerContext.Provider value={{
556
+ globalPlayers, setGlobalPlayers, isLoadingDB, updatePlayerStat, teamId, setTeamId, teamData, setTeamData,
557
+ availableGWs, setAvailableGWs, horizon, setHorizon, activeGW, setActiveGW, captainId, setCaptainId,
558
+ viceId, setViceId, itb, setItb, availableFts, setAvailableFts, initialSquadIds, setInitialSquadIds,
559
+ solverResult, setSolverResult, activeChip, setActiveChip, manualOverrides, setManualOverrides, isLoggedIn,
560
+ setIsLoggedIn, userProfile, setUserProfile, hasGuestMadeEdits, setHasGuestMadeEdits, projSearchTerm, setProjSearchTerm,
561
+ sessionEdits, setSessionEdits, highlightTransferIds, setHighlightTransferIds, transfersByGw, setTransfersByGw,
562
+ chipsByGw, setChipsByGw, baselineItb, setBaselineItb, baselineFt, setBaselineFt, quickSettings, setQuickSettings,
563
+ advancedSettings, setAdvancedSettings, solveElapsedSec, setSolveElapsedSec, solverTransferPairs, setSolverTransferPairs,
564
+ solverApplySnapshot, setSolverApplySnapshot, appliedPlanSummary, setAppliedPlanSummary, hitsThisGw, setHitsThisGw,
565
+ numSims, setNumSims, HIT_COST, comprehensiveSettings, setComprehensiveSettings, saveSession, ftAtStartOfGw, itbAtStartOfGw,
566
+ isCheckingAuth, drafts, setDrafts, activeDraftId, setActiveDraftId,fixtureOverrides, setFixtureOverrides,originalPlayers,
567
+ setOriginalPlayers, globalFixtures, setGlobalFixtures, effectiveFixtures, globalXmins
568
+ }}>
569
+ {children}
570
+ </PlayerContext.Provider>
571
+ );
572
+ };
frontend/src/assets/react.svg ADDED
frontend/src/assets/vite.svg ADDED
frontend/src/components/AccuracyDashboard.jsx ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts';
3
+
4
+ export default function AccuracyDashboard() {
5
+ const [activeTab, setActiveTab] = useState('match outcome');
6
+ const [matchData, setMatchData] = useState([]);
7
+ const [playerData, setPlayerData] = useState([]);
8
+ const [isLoading, setIsLoading] = useState(true);
9
+
10
+ useEffect(() => {
11
+ Promise.all([
12
+ fetch('https://anayshukla-fpl-solver.hf.space/api/accuracy/matches').then(res => res.json()),
13
+ fetch('https://anayshukla-fpl-solver.hf.space/api/accuracy/players').then(res => res.json())
14
+ ]).then(([matches, players]) => {
15
+ setMatchData(matches);
16
+ setPlayerData(players);
17
+ setIsLoading(false);
18
+ });
19
+ }, []);
20
+
21
+ if (isLoading) return <div className="text-luigi-400 animate-pulse p-8">Loading Model Diagnostics...</div>;
22
+
23
+ // --- MATH HELPERS ---
24
+ const calcMetrics = (y_true, y_pred) => {
25
+ const n = y_true.length;
26
+ if (n === 0) return { rmse: 0, mae: 0, r2: 0 };
27
+
28
+ let sumErrSq = 0, sumErrAbs = 0, sumY = 0;
29
+ for (let i = 0; i < n; i++) {
30
+ sumErrSq += Math.pow(y_true[i] - y_pred[i], 2);
31
+ sumErrAbs += Math.abs(y_true[i] - y_pred[i]);
32
+ sumY += y_true[i];
33
+ }
34
+ const meanY = sumY / n;
35
+ let ssTot = 0;
36
+ for (let i = 0; i < n; i++) ssTot += Math.pow(y_true[i] - meanY, 2);
37
+
38
+ return {
39
+ rmse: Math.sqrt(sumErrSq / n),
40
+ mae: sumErrAbs / n,
41
+ r2: ssTot === 0 ? 0 : 1 - (sumErrSq / ssTot)
42
+ };
43
+ };
44
+
45
+ // --- OUTCOME ACCURACY MATH ---
46
+ let globalLL = 0, globalBrier = 0;
47
+ const trendData = [];
48
+
49
+ // --- GOALS & XG MATH ---
50
+ const actGoals = [], projGoals = [], actXG = [];
51
+ const scatterDataGoals = [], scatterDataXG = [];
52
+
53
+ if (matchData.length > 0) {
54
+ let totalMatches = 0;
55
+ const gwMap = matchData.reduce((acc, row) => {
56
+ (acc[row.GW] = acc[row.GW] || []).push(row);
57
+ return acc;
58
+ }, {});
59
+
60
+ Object.keys(gwMap).sort((a,b) => a-b).forEach(gw => {
61
+ const matches = gwMap[gw];
62
+ let gwLL = 0, gwBrier = 0;
63
+
64
+ matches.forEach(m => {
65
+ // Log loss
66
+ const p_h = Math.max(Math.min(m.home_win_prob, 1-1e-15), 1e-15);
67
+ const p_d = Math.max(Math.min(m.draw_prob, 1-1e-15), 1e-15);
68
+ const p_a = Math.max(Math.min(m.away_win_prob, 1-1e-15), 1e-15);
69
+
70
+ const ll = - ((m.home_win * Math.log(p_h)) + (m.draw * Math.log(p_d)) + (m.away_win * Math.log(p_a)));
71
+ gwLL += ll; globalLL += ll;
72
+
73
+ // Brier Score
74
+ const brier = Math.pow(p_h - m.home_win, 2) + Math.pow(p_d - m.draw, 2) + Math.pow(p_a - m.away_win, 2);
75
+ gwBrier += brier; globalBrier += brier;
76
+ totalMatches++;
77
+
78
+ // Goals & xG Collections
79
+ if (m.home_goals !== undefined && m.expected_home_goals !== undefined) {
80
+ actGoals.push(Number(m.home_goals));
81
+ projGoals.push(Number(m.expected_home_goals));
82
+ actXG.push(Number(m.xg_home));
83
+ scatterDataGoals.push({ proj: Number(m.expected_home_goals), act: Number(m.home_goals) });
84
+ scatterDataXG.push({ proj: Number(m.expected_home_goals), act: Number(m.xg_home) });
85
+
86
+ actGoals.push(Number(m.away_goals));
87
+ projGoals.push(Number(m.expected_away_goals));
88
+ actXG.push(Number(m.xg_away));
89
+ scatterDataGoals.push({ proj: Number(m.expected_away_goals), act: Number(m.away_goals) });
90
+ scatterDataXG.push({ proj: Number(m.expected_away_goals), act: Number(m.xg_away) });
91
+ }
92
+ });
93
+
94
+ trendData.push({ gw: `GW${gw}`, weeklyBrier: gwBrier / matches.length, cumBrier: globalBrier / totalMatches });
95
+ });
96
+ globalLL /= totalMatches;
97
+ globalBrier /= totalMatches;
98
+ }
99
+
100
+ const goalsMetrics = calcMetrics(actGoals, projGoals);
101
+ const xgMetrics = calcMetrics(actXG, projGoals);
102
+
103
+ // --- xMINS / xPTS MATH ---
104
+ let ptsMetrics = { rmse: 0, mae: 0, r2: 0 };
105
+ let minsMetrics = { rmse: 0, mae: 0, r2: 0 };
106
+
107
+ if (playerData.length > 0) {
108
+ const actPts = [], pPts = [], actMins = [], pMins = [];
109
+ playerData.forEach(p => {
110
+ for (let gw = 1; gw <= 38; gw++) {
111
+ if (p[`${gw}_xMins`] > 0 && p[`${gw}_Pts`] !== undefined && p[`${gw}_Actuals`] !== undefined) {
112
+ pPts.push(Number(p[`${gw}_Pts`]));
113
+ actPts.push(Number(p[`${gw}_Actuals`]));
114
+ pMins.push(Number(p[`${gw}_xMins`]));
115
+ actMins.push(Number(p[`${gw}_Mins`]) || 0);
116
+ }
117
+ }
118
+ });
119
+ ptsMetrics = calcMetrics(actPts, pPts);
120
+ minsMetrics = calcMetrics(actMins, pMins);
121
+ }
122
+
123
+ return (
124
+ <div className="space-y-6">
125
+ {/* TABS */}
126
+ <div className="flex gap-4 border-b border-slate-800 pb-2">
127
+ {['match outcome', 'goals & xg', 'player_projections'].map(tab => (
128
+ <button
129
+ key={tab}
130
+ onClick={() => setActiveTab(tab)}
131
+ className={`px-4 py-2 text-sm font-bold uppercase tracking-wider transition-colors ${activeTab === tab ? 'text-luigi-400 border-b-2 border-luigi-400' : 'text-slate-500 hover:text-slate-300'}`}
132
+ >
133
+ {tab.replace('_', ' ')}
134
+ </button>
135
+ ))}
136
+ </div>
137
+
138
+ {/* TAB 1: OUTCOME */}
139
+ {activeTab === 'match outcome' && (
140
+ <div className="space-y-6 animate-in fade-in">
141
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
142
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
143
+ <div className="text-slate-400 text-sm font-bold mb-1">Multi-class Log Loss</div>
144
+ <div className="text-4xl font-mono text-slate-100">{globalLL.toFixed(4)}</div>
145
+ </div>
146
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
147
+ <div className="text-slate-400 text-sm font-bold mb-1">Multi-class Brier Score</div>
148
+ <div className="text-4xl font-mono text-luigi-400">{globalBrier.toFixed(4)}</div>
149
+ </div>
150
+ </div>
151
+
152
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm h-[500px]">
153
+ <h3 className="text-lg font-bold text-slate-200 mb-6">Brier Score Trend (Lower is Better)</h3>
154
+ <div className="w-full h-[90%] overflow-x-auto custom-scrollbar">
155
+ <div className="min-w-[600px] h-full">
156
+ <ResponsiveContainer width="100%" height="100%">
157
+ <LineChart data={trendData}>
158
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
159
+ <XAxis dataKey="gw" stroke="#64748b" tick={{fontSize: 12}} />
160
+ <YAxis stroke="#64748b" domain={['auto', 'auto']} reversed />
161
+ <RechartsTooltip contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155' }} />
162
+ <Line type="monotone" dataKey="weeklyBrier" stroke="#64748b" strokeDasharray="5 5" name="Weekly Brier" />
163
+ <Line type="monotone" dataKey="cumBrier" stroke="#34d399" strokeWidth={3} name="Cumulative Brier" />
164
+ </LineChart>
165
+ </ResponsiveContainer>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+ )}
171
+
172
+ {/* TAB 2: GOALS & XG */}
173
+ {activeTab === 'goals & xg' && (
174
+ <div className="space-y-6 animate-in fade-in">
175
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
176
+
177
+ {/* GOALS VS PROJ */}
178
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
179
+ <h3 className="text-lg font-bold text-slate-200 mb-4 border-b border-slate-800 pb-2">Goals v/s Projected</h3>
180
+ <div className="flex gap-6 mb-6">
181
+ <div><span className="text-xs text-slate-500 block">RMSE</span><span className="text-xl font-mono text-slate-200">{goalsMetrics.rmse.toFixed(3)}</span></div>
182
+ <div><span className="text-xs text-slate-500 block">MAE</span><span className="text-xl font-mono text-slate-200">{goalsMetrics.mae.toFixed(3)}</span></div>
183
+ </div>
184
+ <div className="h-[300px] w-full overflow-x-auto custom-scrollbar">
185
+ <div className="min-w-[500px] h-full">
186
+ <ResponsiveContainer width="100%" height="100%">
187
+ <ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
188
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
189
+ <XAxis type="number" dataKey="proj" name="Projected" stroke="#64748b" label={{ value: 'Projected Goals', position: 'insideBottom', offset: -10, fill: '#64748b' }}/>
190
+ <YAxis type="number" dataKey="act" name="Actual" stroke="#64748b" label={{ value: 'Actual Goals', angle: -90, position: 'insideLeft', fill: '#64748b' }}/>
191
+ <RechartsTooltip cursor={{ strokeDasharray: '3 3' }} contentStyle={{backgroundColor: '#0f172a', borderColor: '#334155'}} />
192
+ <Scatter name="Matches" data={scatterDataGoals} fill="#34d399" opacity={0.6} isAnimationActive={false} />
193
+ </ScatterChart>
194
+ </ResponsiveContainer>
195
+ </div>
196
+ </div>
197
+ </div>
198
+
199
+ {/* XG VS PROJ */}
200
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
201
+ <h3 className="text-lg font-bold text-slate-200 mb-4 border-b border-slate-800 pb-2">xG v/s Projected</h3>
202
+ <div className="flex gap-6 mb-6">
203
+ <div><span className="text-xs text-slate-500 block">RMSE</span><span className="text-xl font-mono text-slate-200">{xgMetrics.rmse.toFixed(3)}</span></div>
204
+ <div><span className="text-xs text-slate-500 block">MAE</span><span className="text-xl font-mono text-slate-200">{xgMetrics.mae.toFixed(3)}</span></div>
205
+ </div>
206
+ <div className="h-[300px] w-full overflow-x-auto custom-scrollbar">
207
+ <div className="min-w-[500px] h-full">
208
+ <ResponsiveContainer width="100%" height="100%">
209
+ <ScatterChart margin={{ top: 10, right: 20, bottom: 20, left: 10 }}>
210
+ <CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
211
+ <XAxis type="number" dataKey="proj" name="Projected" stroke="#64748b" label={{ value: 'Projected Goals', position: 'insideBottom', offset: -10, fill: '#64748b' }}/>
212
+ <YAxis type="number" dataKey="act" name="Actual xG" stroke="#64748b" label={{ value: 'xG Generated', angle: -90, position: 'insideLeft', fill: '#64748b' }}/>
213
+ <RechartsTooltip cursor={{ strokeDasharray: '3 3' }} contentStyle={{backgroundColor: '#0f172a', borderColor: '#334155'}} />
214
+ <Scatter name="Matches" data={scatterDataXG} fill="#f43f5e" opacity={0.6} isAnimationActive={false} />
215
+ </ScatterChart>
216
+ </ResponsiveContainer>
217
+ </div>
218
+ </div>
219
+ </div>
220
+
221
+ </div>
222
+ </div>
223
+ )}
224
+
225
+ {/* TAB 3: PLAYER STATS */}
226
+ {activeTab === 'player_projections' && (
227
+ <div className="space-y-6 animate-in fade-in">
228
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
229
+
230
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
231
+ {/* Updated Title */}
232
+ <h3 className="text-lg font-bold text-slate-200 mb-4 border-b border-slate-800 pb-2">xMins Accuracy (0&lt; xMins)</h3>
233
+ <div className="grid grid-cols-3 gap-4">
234
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
235
+ <div className="text-xs text-slate-500 mb-1">R2 Score</div>
236
+ <div className="text-xl font-mono text-cyan-400">{minsMetrics.r2.toFixed(3)}</div>
237
+ </div>
238
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
239
+ <div className="text-xs text-slate-500 mb-1">MAE</div>
240
+ <div className="text-xl font-mono text-slate-200">{minsMetrics.mae.toFixed(3)}</div>
241
+ </div>
242
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
243
+ <div className="text-xs text-slate-500 mb-1">RMSE</div>
244
+ <div className="text-xl font-mono text-slate-200">{minsMetrics.rmse.toFixed(3)}</div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <div className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm">
250
+ {/* Updated Title */}
251
+ <h3 className="text-lg font-bold text-slate-200 mb-4 border-b border-slate-800 pb-2">xPts Accuracy (0&lt; xPts)</h3>
252
+ <div className="grid grid-cols-3 gap-4">
253
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
254
+ <div className="text-xs text-slate-500 mb-1">R2 Score</div>
255
+ <div className="text-xl font-mono text-luigi-400">{ptsMetrics.r2.toFixed(3)}</div>
256
+ </div>
257
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
258
+ <div className="text-xs text-slate-500 mb-1">MAE</div>
259
+ <div className="text-xl font-mono text-slate-200">{ptsMetrics.mae.toFixed(3)}</div>
260
+ </div>
261
+ <div className="bg-slate-950 p-4 rounded-lg border border-slate-800/50">
262
+ <div className="text-xs text-slate-500 mb-1">RMSE</div>
263
+ <div className="text-xl font-mono text-slate-200">{ptsMetrics.rmse.toFixed(3)}</div>
264
+ </div>
265
+ </div>
266
+ </div>
267
+
268
+ </div>
269
+ </div>
270
+ )}
271
+ </div>
272
+ );
273
+ }
frontend/src/components/ActiveMovesPanel.jsx ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { ArrowRightLeft } from "lucide-react";
3
+ import { CHIP_CONFIG, getPlayerPrice } from "../utils/fplLogic";
4
+
5
+ export const ActiveMovesPanel = ({
6
+ activeGW,
7
+ manualOverrides,
8
+ globalPlayers,
9
+ chipsByGw,
10
+ transfersByGw
11
+ }) => {
12
+ const activeLock = manualOverrides[activeGW] || {};
13
+ const manualTransfers = activeLock.manualTransfers || {};
14
+ const chip = chipsByGw[activeGW];
15
+ const tData = transfersByGw[activeGW] || { count: 0, netDelta: 0 };
16
+
17
+ const moveEntries = Object.entries(manualTransfers);
18
+ const hasMoves = moveEntries.length > 0;
19
+
20
+ return (
21
+ <div className="w-full bg-slate-950 border border-slate-800 rounded-2xl flex flex-col shadow-2xl overflow-hidden shrink-0 transition-all duration-300">
22
+
23
+ {/* Header */}
24
+ <div className="border-b border-slate-800 px-4 py-3 bg-slate-900/80 flex justify-between items-center">
25
+ <h2 className="text-xs font-black uppercase tracking-widest text-slate-300 flex items-center gap-2">
26
+ <ArrowRightLeft size={14} className="text-cyan-400" />
27
+ GW {activeGW} Moves Made So Far
28
+ </h2>
29
+ {chip && CHIP_CONFIG[chip] && (
30
+ <span className={`text-[9px] font-black px-1.5 py-0.5 rounded ${CHIP_CONFIG[chip].badge}`}>
31
+ {CHIP_CONFIG[chip].short}
32
+ </span>
33
+ )}
34
+ </div>
35
+
36
+ {/* Body */}
37
+ <div className="p-4 flex flex-col gap-2">
38
+ {!hasMoves ? (
39
+ <div className="text-center text-slate-500 text-[10px] italic py-2">
40
+ No active transfers made for GW {activeGW}.
41
+ </div>
42
+ ) : (
43
+ moveEntries.map(([inIdStr, outPlayer], idx) => {
44
+ const isBlankIn = inIdStr.startsWith("blank_");
45
+ const inPlayer = !isBlankIn ? globalPlayers.find(p => String(p.ID) === inIdStr) : null;
46
+
47
+ return (
48
+ <div key={idx} className="flex items-center justify-between bg-slate-900/50 border border-slate-800/80 rounded-lg p-2.5 font-mono text-xs shadow-inner">
49
+
50
+ {/* Outgoing Player */}
51
+ <div className="flex-1 min-w-0 flex flex-col">
52
+ <span className="text-red-400 truncate font-bold">{outPlayer?.Name || "Unknown"}</span>
53
+ <span className="text-slate-500 text-[9px]">£{(outPlayer ? getPlayerPrice(outPlayer) : 0).toFixed(1)}m</span>
54
+ </div>
55
+
56
+ <span className="text-slate-600 font-black px-3">»</span>
57
+
58
+ {/* Incoming Player */}
59
+ <div className="flex-1 min-w-0 flex flex-col items-end text-right">
60
+ {isBlankIn ? (
61
+ <>
62
+ <span className="text-yellow-500/70 italic truncate text-[11px] mt-0.5">Select Player...</span>
63
+ </>
64
+ ) : (
65
+ <>
66
+ <span className="text-emerald-400 truncate font-bold">{inPlayer?.Name || inIdStr}</span>
67
+ <span className="text-slate-500 text-[9px]">£{(inPlayer ? getPlayerPrice(inPlayer) : 0).toFixed(1)}m</span>
68
+ </>
69
+ )}
70
+ </div>
71
+
72
+ </div>
73
+ );
74
+ })
75
+ )}
76
+
77
+ {/* Summary Footer */}
78
+ {hasMoves && (
79
+ <div className="mt-2 pt-3 border-t border-slate-800/80 flex justify-between items-center text-[10px] font-bold">
80
+ <span className="text-slate-400 uppercase tracking-wider">
81
+ Total Moves: <span className="text-cyan-400 font-mono text-xs ml-1">{tData.count}</span>
82
+ </span>
83
+ <span className="text-slate-400 uppercase tracking-wider">
84
+ Bank Delta:
85
+ <span className={`font-mono text-xs ml-1 ${tData.netDelta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
86
+ {tData.netDelta > 0 ? '+' : ''}{tData.netDelta.toFixed(1)}m
87
+ </span>
88
+ </span>
89
+ </div>
90
+ )}
91
+ </div>
92
+ </div>
93
+ );
94
+ };
frontend/src/components/AdvancedSettingsModal.jsx ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { X, Power, Info, RotateCcw } from "lucide-react";
3
+
4
+ // The absolute baseline FPL defaults as defined in your comprehensive_settings.json
5
+ export const DEFAULT_SETTINGS = {
6
+ enabled: false,
7
+ secs: 600,
8
+ hit_limit: 0,
9
+ no_transfer_last_gws: 0,
10
+ keep_top_ev_percent: 7,
11
+ ft_use_penalty: 0.1,
12
+ itb_loss_per_transfer: 0.0,
13
+ vcap_weight: 0.1,
14
+ opposing_play_penalty: 0.5,
15
+ use_ft_value_list: false, // Sub-toggle for FT behavior
16
+ ft_value: 1.5,
17
+ xmin_lb: 45,
18
+ ft_value_list: { "2": 2, "3": 1.6, "4": 1.3, "5": 1.1 },
19
+ bench_weights: { "0": 0.03, "1": 0.21, "2": 0.06, "3": 0.002 },
20
+ randomization_strength: 1.0,
21
+ iteration_criteria: "this_gw_transfer_in_out",
22
+ iteration_diff: 2,
23
+ };
24
+
25
+ export function AdvancedSettingsModal({
26
+ setShowAdvancedSettings,
27
+ comprehensiveSettings,
28
+ setComprehensiveSettings,
29
+ }) {
30
+ const [isEnabled, setIsEnabled] = useState(
31
+ comprehensiveSettings.enabled ?? false
32
+ );
33
+
34
+ const handleToggle = () => {
35
+ const newState = !isEnabled;
36
+ setIsEnabled(newState);
37
+ setComprehensiveSettings((prev) => ({ ...prev, enabled: newState }));
38
+ };
39
+
40
+ const handleResetToDefaults = () => {
41
+ if (window.confirm("Are you sure you want to restore all advanced settings to their recommended defaults?")) {
42
+ // Restore all defaults, but preserve whatever the current toggle state is!
43
+ setComprehensiveSettings({ ...DEFAULT_SETTINGS, enabled: isEnabled });
44
+ }
45
+ };
46
+
47
+ const handleChange = (key, value, nestedKey = null) => {
48
+ setComprehensiveSettings((prev) => {
49
+ if (nestedKey !== null) {
50
+ return {
51
+ ...prev,
52
+ [key]: {
53
+ ...(prev[key] || {}),
54
+ [nestedKey]: value,
55
+ },
56
+ };
57
+ }
58
+ return { ...prev, [key]: value };
59
+ });
60
+ };
61
+
62
+ // Check if we are using the dynamic list or the flat value
63
+ const isUsingFtList = comprehensiveSettings.use_ft_value_list ?? DEFAULT_SETTINGS.use_ft_value_list;
64
+
65
+ const SETTINGS_GROUPS = [
66
+ {
67
+ title: "Solver Constraints",
68
+ items: [
69
+ { key: "secs", label: "Solve Time Limit (secs)", type: "number", step: "1", default: DEFAULT_SETTINGS.secs, desc: "Maximum time in seconds allowed for the solver per iteration." },
70
+ { key: "hit_limit", label: "Max Horizon Hits", type: "number", step: "1", default: DEFAULT_SETTINGS.hit_limit, desc: "Maximum total hits allowed over the entire horizon. Leave blank for infinite." },
71
+ { key: "no_transfer_last_gws", label: "No Transfers Last X GWs", type: "number", step: "1", default: DEFAULT_SETTINGS.no_transfer_last_gws, desc: "Prevent transfers in the final X gameweeks of the horizon." },
72
+ { key: "xmin_lb", label: "Min xMins (Per GW)", type: "number", step: "1", default: DEFAULT_SETTINGS.xmin_lb, desc: "Minimum expected minutes per GW. Multiplied by the horizon length to filter out non-playing players before solving." },
73
+ { key: "keep_top_ev_percent", label: "Keep Top EV (%)", type: "number", step: "1", default: DEFAULT_SETTINGS.keep_top_ev_percent, desc: "Percentage of top EV players to keep for the solve." },
74
+ ]
75
+ },
76
+ {
77
+ title: "Penalties & Weights",
78
+ items: [
79
+ { key: "ft_use_penalty", label: "FT Use Penalty", type: "number", step: "0.01", default: DEFAULT_SETTINGS.ft_use_penalty, desc: "Penalty applied for using a free transfer (encourages rolling)." },
80
+ { key: "itb_loss_per_transfer", label: "ITB Loss per Transfer", type: "number", step: "0.01", default: DEFAULT_SETTINGS.itb_loss_per_transfer, desc: "Artificial cost deducted from ITB per transfer to prefer cheaper identical-EV moves." },
81
+ { key: "vcap_weight", label: "Vice-Captain Weight", type: "number", step: "0.01", default: DEFAULT_SETTINGS.vcap_weight, desc: "Fractional EV added to the Vice Captain in case the main captain does not play." },
82
+ { key: "opposing_play_penalty", label: "Opposing Play Penalty", type: "number", step: "0.01", default: DEFAULT_SETTINGS.opposing_play_penalty, desc: "Penalty applied when attacking players face defensive players in your lineup." },
83
+ ]
84
+ },
85
+ {
86
+ title: "Bench Weights",
87
+ desc: "Fractional EV added to bench players based on their bench order.",
88
+ isNested: true,
89
+ parentKey: "bench_weights",
90
+ items: [
91
+ { nestedKey: "0", label: "Goalkeeper", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["0"] },
92
+ { nestedKey: "1", label: "Outfield 1st", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["1"] },
93
+ { nestedKey: "2", label: "Outfield 2nd", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["2"] },
94
+ { nestedKey: "3", label: "Outfield 3rd", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["3"] },
95
+ ]
96
+ },
97
+ {
98
+ title: "Iterations & Simulations",
99
+ items: [
100
+ { key: "randomization_strength", label: "Randomization Strength", type: "number", step: "0.01", default: DEFAULT_SETTINGS.randomization_strength, desc: "Multiplier/Strength for adding noise to EVs during Sensitivity Analysis." },
101
+ { key: "iteration_criteria", label: "Iteration Criteria", type: "select", default: DEFAULT_SETTINGS.iteration_criteria, desc: "Rule to generate alternative solutions in subsequent iterations.",
102
+ options: [
103
+ { value: "this_gw_transfer_in_out", label: "Transfers In & Out (Current GW)" },
104
+ { value: "this_gw_transfer_in", label: "Transfers In (Current GW)" },
105
+ { value: "this_gw_transfer_out", label: "Transfers Out (Current GW)" },
106
+ ]
107
+ },
108
+ { key: "iteration_diff", label: "Iteration Difference", type: "number", step: "1", default: DEFAULT_SETTINGS.iteration_diff, desc: "Minimum number of transfers that must change to find an alternate optimal solution." }
109
+ ]
110
+ }
111
+ ];
112
+
113
+ return (
114
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
115
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-2xl flex flex-col shadow-2xl">
116
+
117
+ {/* Header */}
118
+ <div className="sticky top-0 z-30 bg-slate-950/90 backdrop-blur-md border-b border-slate-800 px-6 py-4 flex items-center justify-between">
119
+ <div>
120
+ <h3 className="text-xl font-black text-slate-100 flex items-center gap-2">
121
+ Advanced Algorithm Settings
122
+ </h3>
123
+ <p className="text-xs text-slate-400 mt-1">Configure comprehensive internal MILP parameters and weights.</p>
124
+ </div>
125
+ <div className="flex items-center gap-3">
126
+ <button onClick={handleResetToDefaults} className="flex items-center gap-1.5 text-xs font-bold text-slate-400 hover:text-red-400 transition-colors bg-slate-900 px-3 py-2 rounded-lg border border-slate-800 hover:border-red-500/30">
127
+ <RotateCcw size={14} /> Defaults
128
+ </button>
129
+ <button onClick={() => setShowAdvancedSettings(false)} className="text-slate-500 hover:text-white transition-colors bg-slate-900 p-2 rounded-lg border border-slate-800">
130
+ <X size={20} />
131
+ </button>
132
+ </div>
133
+ </div>
134
+
135
+ {/* Body */}
136
+ <div className="p-6 flex flex-col gap-8">
137
+
138
+ {/* Master Toggle */}
139
+ <div className={`p-4 rounded-xl border flex items-center justify-between transition-colors shadow-lg ${isEnabled ? 'bg-luigi-500/10 border-luigi-500/30' : 'bg-slate-900 border-slate-700'}`}>
140
+ <div className="flex items-center gap-4">
141
+ <div className={`p-2.5 rounded-lg ${isEnabled ? 'bg-luigi-500/20 text-luigi-400' : 'bg-slate-800 text-slate-500'}`}>
142
+ <Power size={22} />
143
+ </div>
144
+ <div>
145
+ <h4 className={`font-bold text-lg ${isEnabled ? 'text-luigi-400' : 'text-slate-400'}`}>Enable Advanced Overrides</h4>
146
+ <p className="text-xs text-slate-500">When enabled, these parameters will be injected into the solver payload.</p>
147
+ </div>
148
+ </div>
149
+ <button
150
+ onClick={handleToggle}
151
+ className={`relative inline-flex h-7 w-12 items-center rounded-full transition-colors focus:outline-none ${isEnabled ? 'bg-luigi-500' : 'bg-slate-700'}`}
152
+ >
153
+ <span className={`inline-block h-5 w-5 transform rounded-full bg-white transition-transform ${isEnabled ? 'translate-x-6' : 'translate-x-1'}`} />
154
+ </button>
155
+ </div>
156
+
157
+ <div className={`flex flex-col gap-8 transition-opacity duration-300 ${!isEnabled ? 'opacity-30 pointer-events-none grayscale' : 'opacity-100'}`}>
158
+
159
+ {/* SPECIAL SECTION: Free Transfer Valuation */}
160
+ <div className="bg-slate-900/40 rounded-2xl p-5 border border-slate-800/60">
161
+ <div className="mb-5 border-b border-slate-800 pb-3 flex items-center justify-between">
162
+ <div>
163
+ <h4 className="text-sm font-black text-slate-400 uppercase tracking-widest">FT Val</h4>
164
+ <p className="text-[11px] text-slate-500 mt-1">Intrinsic EV value assigned for holding/rolling free transfers.</p>
165
+ </div>
166
+ <div className="flex items-center gap-2">
167
+ <span className="text-xs font-bold text-slate-400">Dynamic List</span>
168
+ <button onClick={() => handleChange("use_ft_value_list", !isUsingFtList)} className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none ${isUsingFtList ? 'bg-luigi-500' : 'bg-slate-700'}`}>
169
+ <span className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${isUsingFtList ? 'translate-x-5' : 'translate-x-1'}`} />
170
+ </button>
171
+ </div>
172
+ </div>
173
+
174
+ {isUsingFtList ? (
175
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
176
+ {["2", "3", "4", "5"].map((num) => (
177
+ <div key={`ft_${num}`} className="bg-slate-900 border border-slate-700 p-4 rounded-xl">
178
+ <label className="text-xs font-bold text-slate-300 block mb-2">Value of {num}{num==='2'?'nd':num==='3'?'rd':'th'} FT</label>
179
+ <input type="number" step="0.1" value={comprehensiveSettings.ft_value_list?.[num] ?? DEFAULT_SETTINGS.ft_value_list[num]} onChange={(e) => handleChange("ft_value_list", parseFloat(e.target.value) || 0, num)} className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:border-luigi-500 font-mono" />
180
+ </div>
181
+ ))}
182
+ </div>
183
+ ) : (
184
+ <div className="bg-slate-900/50 border border-slate-700 p-4 rounded-xl flex items-center justify-center text-center h-[88px]">
185
+ <p className="text-xs text-slate-400 font-bold">Using standard flat FT Value from normal settings.</p>
186
+ </div>
187
+ )}
188
+ </div>
189
+
190
+ {/* Render Remaining Standard Settings Groups */}
191
+ {SETTINGS_GROUPS.map((group, idx) => (
192
+ <div key={idx} className="bg-slate-900/40 rounded-2xl p-5 border border-slate-800/60">
193
+ <div className="mb-5 border-b border-slate-800 pb-3">
194
+ <h4 className="text-sm font-black text-slate-400 uppercase tracking-widest">{group.title}</h4>
195
+ {group.desc && <p className="text-[11px] text-slate-500 mt-1">{group.desc}</p>}
196
+ </div>
197
+
198
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
199
+ {group.items.map((item) => {
200
+ let val = group.isNested
201
+ ? (comprehensiveSettings[group.parentKey]?.[item.nestedKey] ?? item.default)
202
+ : (comprehensiveSettings[item.key] ?? item.default);
203
+
204
+ return (
205
+ <div key={item.key || item.nestedKey} className="bg-slate-900 border border-slate-700 p-4 rounded-xl relative group hover:border-slate-500 transition-colors">
206
+ <div className="flex justify-between items-center mb-2">
207
+ <label className="text-xs font-bold text-slate-300">{item.label}</label>
208
+ <Info size={14} className="text-slate-600 group-hover:text-luigi-400 transition-colors" />
209
+ </div>
210
+
211
+ {item.type === "select" ? (
212
+ <select value={val} onChange={(e) => handleChange(item.key, e.target.value)} className="w-full bg-slate-950 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-100 focus:outline-none focus:border-luigi-500 cursor-pointer">
213
+ {item.options.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
214
+ </select>
215
+ ) : (
216
+ <input
217
+ type={item.type}
218
+ step={item.step}
219
+ min={item.min}
220
+ // THE FIX 1: If it's a checkbox, use 'checked'. Otherwise use 'value'.
221
+ checked={item.type === "checkbox" ? Boolean(val) : undefined}
222
+ value={item.type !== "checkbox" ? (val === "" ? "" : val) : undefined}
223
+ placeholder={item.default === "" ? "None" : item.default}
224
+ onChange={(e) => {
225
+ // THE FIX 2: Safely extract boolean for checkboxes, numbers for everything else
226
+ let newVal;
227
+ if (item.type === "checkbox") {
228
+ newVal = e.target.checked;
229
+ } else {
230
+ newVal = e.target.value === "" ? "" : (parseFloat(e.target.value) || 0);
231
+ }
232
+
233
+ group.isNested ? handleChange(group.parentKey, newVal, item.nestedKey) : handleChange(item.key, newVal);
234
+ }}
235
+ className={`bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-100 focus:outline-none focus:border-luigi-500 font-mono ${item.type === 'checkbox' ? 'w-5 h-5 accent-luigi-500 cursor-pointer' : 'w-full px-3 py-2'}`}
236
+ />
237
+ )}
238
+
239
+ <div className="absolute left-0 -bottom-2 translate-y-full opacity-0 group-hover:opacity-100 transition-opacity z-20 w-[110%] bg-slate-800 text-slate-300 text-[11px] p-2.5 rounded-lg shadow-xl pointer-events-none border border-slate-700">
240
+ {item.desc || group.desc}
241
+ </div>
242
+ </div>
243
+ );
244
+ })}
245
+ </div>
246
+ </div>
247
+ ))}
248
+ </div>
249
+
250
+ </div>
251
+ </div>
252
+ </div>
253
+ );
254
+ }
frontend/src/components/DraftsComparisonTable.jsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Trash2 } from "lucide-react";
3
+
4
+ export const DraftsComparisonTable = ({
5
+ drafts, horizonGWs, activeDraftId, globalPlayers,
6
+ setActiveDraftId, getValidLayout, availableGWs,
7
+ baselineFt, ftAtStartOfGw, setDrafts
8
+ }) => {
9
+ if (!drafts || drafts.length === 0) return null;
10
+
11
+ const handleDeleteDraft = (e, id) => {
12
+ e.stopPropagation(); // Prevents the row click from triggering
13
+ if (drafts.length <= 1) return;
14
+ const nextDrafts = drafts.filter(d => d.id !== id);
15
+ setDrafts(nextDrafts);
16
+ if (activeDraftId === id) setActiveDraftId(nextDrafts[0].id);
17
+ };
18
+
19
+ const getDraftGwState = (draft, gw) => {
20
+ if (draft.cachedEvs && draft.cachedEvs[gw]) {
21
+ return draft.cachedEvs[gw];
22
+ }
23
+ const chip = draft.chipsByGw?.[gw];
24
+ const capMult = chip === "tc" ? 3 : 2;
25
+ let squad = [];
26
+ let capId = null;
27
+
28
+ if (gw === draft.activeGW) {
29
+ squad = draft.teamData;
30
+ capId = draft.captainId;
31
+ } else {
32
+ const lock = draft.manualOverrides?.[gw];
33
+ if (lock && lock.ids && lock.ids.length === 15) {
34
+ squad = lock.ids.map(id => draft.teamData?.find(p => String(p.ID) === String(id)) || globalPlayers.find(p => String(p.ID) === String(id))).filter(Boolean);
35
+ capId = lock.cap;
36
+ if (squad.length !== 15) {
37
+ const opt = getValidLayout(draft.teamData, gw);
38
+ if (opt) { squad = opt.optimalArray; capId = opt.cap; }
39
+ }
40
+ } else {
41
+ const opt = getValidLayout(draft.teamData, gw);
42
+ if (opt) { squad = opt.optimalArray; capId = opt.cap; }
43
+ }
44
+ }
45
+
46
+ let gwPts = 0;
47
+ if (squad && squad.length === 15) {
48
+ squad.slice(0, 11).forEach(p => {
49
+ if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (String(p.ID) === String(capId) ? capMult : 1);
50
+ });
51
+ let ofIdx = 0;
52
+ squad.slice(11, 15).forEach(p => {
53
+ if (!p.isBlank) {
54
+ if (chip === "bb") gwPts += (Number(p[`${gw}_Pts`]) || 0);
55
+ else if (p.Pos === "G") gwPts += (Number(p[`${gw}_Pts`]) || 0) * 0.04;
56
+ else { gwPts += (Number(p[`${gw}_Pts`]) || 0) * ([0.17, 0.05, 0.02][ofIdx] || 0.02); ofIdx++; }
57
+ }
58
+ });
59
+ }
60
+
61
+ const ftStart = ftAtStartOfGw(gw, availableGWs, baselineFt, draft.transfersByGw || {}, draft.chipsByGw || {});
62
+ const moves = draft.transfersByGw?.[gw]?.count || 0;
63
+ const isChipFree = chip === "wc" || chip === "fh";
64
+ const hits = isChipFree ? 0 : Math.max(0, moves - ftStart);
65
+
66
+ return { ev: gwPts - (hits * 4), chip, hits, ftStart, moves, isChipFree };
67
+ };
68
+
69
+ return (
70
+ <div className="w-full bg-[#0a0f1c] border border-[#2a2d5c] rounded-xl shadow-[0_0_30px_rgba(42,45,92,0.4)] overflow-hidden mb-6">
71
+ <div className="overflow-x-auto custom-scrollbar">
72
+ <table className="w-full text-center font-mono whitespace-nowrap">
73
+ <thead>
74
+ <tr className="text-slate-400 text-[9px] uppercase tracking-widest border-b border-[#2a2d5c] bg-[#050811]">
75
+ <th className="py-2 px-4 text-left font-black w-40">Timeline</th>
76
+ {horizonGWs.map(gw => (
77
+ <th key={gw} className="py-2 px-3 border-l border-[#1a1c3a]">GW{gw}</th>
78
+ ))}
79
+ <th className="py-2 px-4 text-emerald-400 font-black border-l border-[#2a2d5c] w-24 shadow-[inset_10px_0_20px_rgba(0,0,0,0.2)]">Total EV</th>
80
+ </tr>
81
+ </thead>
82
+ <tbody className="divide-y divide-[#1a1c3a]">
83
+ {drafts.map((draft) => {
84
+ const isActive = draft.id === activeDraftId;
85
+ const rowData = horizonGWs.map(gw => getDraftGwState(draft, gw));
86
+ const totalEv = rowData.reduce((sum, d) => sum + d.ev, 0);
87
+
88
+ return (
89
+ <tr
90
+ key={draft.id}
91
+ onClick={() => setActiveDraftId(draft.id)}
92
+ className={`transition-all cursor-pointer group ${isActive ? "bg-[#1e2247]/60" : "bg-[#0a0f1c] hover:bg-[#151833]"}`}
93
+ >
94
+ <td className="py-2.5 px-4 border-r border-[#1a1c3a]">
95
+ <div className="flex items-center justify-between w-32">
96
+ <div className={`font-bold text-[11px] truncate transition-colors ${isActive ? "text-white" : "text-slate-400 group-hover:text-slate-200"}`}>
97
+ {draft.name}
98
+ </div>
99
+ {/* FIX: Native Flexbox Delete Button (always clickable, visually clean) */}
100
+ {drafts.length > 1 && (
101
+ <button
102
+ onClick={(e) => handleDeleteDraft(e, draft.id)}
103
+ className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded-md transition-all flex-shrink-0"
104
+ title="Delete Timeline"
105
+ >
106
+ <Trash2 size={12} />
107
+ </button>
108
+ )}
109
+ </div>
110
+ </td>
111
+
112
+ {rowData.map((d, i) => (
113
+ <td key={i} className="py-2 px-3 border-l border-[#1a1c3a] relative">
114
+ <div className={`text-[15px] font-black tracking-tight drop-shadow-md ${isActive ? (d.ev > 55 ? 'text-[#818cf8]' : 'text-indigo-200') : 'text-slate-500'}`}>
115
+ {d.ev.toFixed(1)}
116
+ </div>
117
+ <div className="flex justify-center items-center gap-1.5 mt-1">
118
+ <span className="text-[8px] font-black uppercase text-[#c084fc] w-3 text-left">{d.chip || ""}</span>
119
+ <span className={`text-[8.5px] font-bold flex items-center gap-1.5 ${isActive ? 'text-indigo-300' : 'text-slate-600'}`}>
120
+ <span>{d.hits > 0 ? `-${d.hits*4}` : "-"}</span>
121
+ <span>{d.isChipFree ? `${d.moves}/∞` : `${d.moves}/${d.ftStart}`}</span>
122
+ </span>
123
+ </div>
124
+ </td>
125
+ ))}
126
+
127
+ <td className={`py-2 px-4 border-l border-[#2a2d5c] shadow-[inset_10px_0_20px_rgba(0,0,0,0.2)] ${isActive ? 'bg-[#050811]/40' : 'bg-[#050811]/80'}`}>
128
+ <div className={`text-lg font-black drop-shadow-[0_0_10px_rgba(52,211,153,0.3)] ${isActive ? 'text-emerald-400' : 'text-emerald-700'}`}>
129
+ {totalEv.toFixed(1)}
130
+ </div>
131
+ </td>
132
+ </tr>
133
+ );
134
+ })}
135
+ </tbody>
136
+ </table>
137
+ </div>
138
+ </div>
139
+ );
140
+ };
frontend/src/components/DraggablePlayer.jsx ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useDraggable, useDroppable } from "@dnd-kit/core";
3
+ import { CSS } from "@dnd-kit/utilities";
4
+ import { PlayerCardVisual } from "./PlayerCardVisual"; // Import the visual component we just made
5
+
6
+ export const DraggablePlayer = ({
7
+ player,
8
+ isBench,
9
+ benchIndex,
10
+ isActiveDrag,
11
+ isValidTarget,
12
+ captainId,
13
+ viceId,
14
+ handleCapChange,
15
+ playerCardGWs,
16
+ fixtures,
17
+ activeGW,
18
+ onPlayerClick,
19
+ onUndo,
20
+ isHighlighted,
21
+ onSolverUndo,
22
+ activeChipType,
23
+ }) => {
24
+ const disableBenchGkDrag = Boolean(
25
+ isBench && benchIndex === 0 && player.Pos === "G" && !player.isBlank,
26
+ );
27
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
28
+ useDraggable({
29
+ id: player.ID,
30
+ data: { player, isBench },
31
+ disabled: Boolean(player.isBlank),
32
+ });
33
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
34
+ id: player.ID,
35
+ data: { player, isBench },
36
+ });
37
+
38
+ const style = {
39
+ transform: CSS.Translate.toString(transform),
40
+ zIndex: isDragging ? 50 : 20,
41
+ opacity: isDragging ? 0.6 : isActiveDrag && !isValidTarget ? 0.3 : 1,
42
+ filter: isOver && isValidTarget && !isDragging ? "brightness(1.5)" : "none",
43
+ };
44
+
45
+ return (
46
+ <div
47
+ ref={(node) => {
48
+ setNodeRef(node);
49
+ setDropRef(node);
50
+ }}
51
+ style={style}
52
+ {...listeners}
53
+ {...attributes}
54
+ className={`touch-none select-none rounded-xl transition-[box-shadow,filter] duration-200 ${isHighlighted ? "transfer-highlight-card ring-2 ring-cyan-400/40" : ""}`}
55
+ >
56
+ <PlayerCardVisual
57
+ player={player}
58
+ isBench={isBench}
59
+ captainId={captainId}
60
+ viceId={viceId}
61
+ handleCapChange={handleCapChange}
62
+ playerCardGWs={playerCardGWs}
63
+ fixtures={fixtures}
64
+ activeGW={activeGW}
65
+ onPlayerClick={() => onPlayerClick(player)}
66
+ onUndo={onUndo}
67
+ onSolverUndo={onSolverUndo}
68
+ activeChipType={activeChipType}
69
+ />
70
+ </div>
71
+ );
72
+ };
frontend/src/components/FixtureMatrixPanel.jsx ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useMemo, useState, useRef, useContext } from "react";
2
+ import { Plus, Trash2, Zap, Search, X, Database } from "lucide-react";
3
+ import { PlayerContext } from '../PlayerContext';
4
+
5
+ export const FixtureMatrixPanel = ({
6
+ globalPlayers,
7
+ fixtureOverrides,
8
+ setFixtureOverrides,
9
+ availableGWs
10
+ }) => {
11
+ const { globalFixtures = {}, effectiveFixtures = {} } = useContext(PlayerContext);
12
+ const [search, setSearch] = useState("");
13
+
14
+ // --- ADMIN BACKDOOR STATE ---
15
+ const [isAdmin, setIsAdmin] = useState(false);
16
+ const [adminPassword, setAdminPassword] = useState('');
17
+ const [showAdminLogin, setShowAdminLogin] = useState(false);
18
+ const [clickCount, setClickCount] = useState(0);
19
+ const clickTimeoutRef = useRef(null);
20
+
21
+ const handleSecretClick = () => {
22
+ setClickCount((prev) => {
23
+ const newCount = prev + 1;
24
+ if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; }
25
+ return newCount;
26
+ });
27
+ if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current);
28
+ clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000);
29
+ };
30
+
31
+ const handlePublishGlobal = async () => {
32
+ if (!window.confirm("WARNING: This will overwrite the live FPL database for ALL users. Proceed?")) return;
33
+
34
+ try {
35
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/update', {
36
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
37
+ body: JSON.stringify({
38
+ is_admin: isAdmin, admin_password: adminPassword,
39
+ overrides: { ...globalFixtures, ...fixtureOverrides } // Merges admin edits with existing globals
40
+ })
41
+ });
42
+ if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend publish failed'); }
43
+ alert("Success! Global fixtures updated. All users will see these on refresh.");
44
+ } catch (err) { console.error("Publish error:", err); }
45
+ };
46
+
47
+ // 1. Extract all matches
48
+ const allMatches = useMemo(() => {
49
+ const TEAM_SHORTS = {
50
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
51
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
52
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
53
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
54
+ };
55
+
56
+ const matchMap = new Map();
57
+ globalPlayers.forEach(p => {
58
+ if (p.match_projections) {
59
+ Object.entries(p.match_projections).forEach(([matchId, data]) => {
60
+ if (!matchMap.has(matchId)) {
61
+ const [homeId, awayId] = matchId.split("_vs_");
62
+ const hName = TEAM_SHORTS[homeId] || homeId;
63
+ const aName = TEAM_SHORTS[awayId] || awayId;
64
+
65
+ matchMap.set(matchId, {
66
+ id: matchId,
67
+ homeTeam: hName,
68
+ awayTeam: aName,
69
+ defaultGw: data.default_gw,
70
+ searchString: `${hName} ${aName}`.toLowerCase()
71
+ });
72
+ }
73
+ });
74
+ }
75
+ });
76
+ return Array.from(matchMap.values()).sort((a, b) => a.defaultGw - b.defaultGw);
77
+ }, [globalPlayers]);
78
+
79
+ const activeSplits = allMatches.filter(m => effectiveFixtures[m.id]);
80
+ const searchResults = search
81
+ ? allMatches.filter(m => !effectiveFixtures[m.id] && m.searchString.includes(search.toLowerCase())).slice(0, 10)
82
+ : [];
83
+
84
+ // --- HANDLERS ---
85
+ const handleAddOverride = (match) => {
86
+ // THE FIX 4b: If they search a hidden global fixture, load its true splits! Otherwise default to 100%.
87
+ const initialSplit = globalFixtures[match.id] ? { ...globalFixtures[match.id] } : { [match.defaultGw]: 1.0 };
88
+ setFixtureOverrides(prev => ({ ...prev, [match.id]: initialSplit }));
89
+ setSearch("");
90
+ };
91
+
92
+ // 1. Create a "Blank" Split Row
93
+ const handleAddSplitGw = (matchId) => {
94
+ setFixtureOverrides(prev => {
95
+ const next = { ...prev };
96
+ const tempId = `unselected_${Date.now()}`;
97
+ next[matchId] = { ...next[matchId], [tempId]: 0.0 };
98
+ return next;
99
+ });
100
+ };
101
+
102
+ // Convert the "Blank" row into a real GW when user selects from dropdown
103
+ const handleChangeSplitGw = (matchId, oldGw, newGw) => {
104
+ setFixtureOverrides(prev => {
105
+ const next = { ...prev };
106
+ const matchOverrides = { ...next[matchId] };
107
+ const prob = matchOverrides[oldGw];
108
+ delete matchOverrides[oldGw];
109
+ matchOverrides[newGw] = prob;
110
+ next[matchId] = matchOverrides;
111
+ return next;
112
+ });
113
+ };
114
+
115
+ // 2. The Auto-Balancer Engine (Always enforces 100% sum)
116
+ const handleUpdateSplit = (matchId, gw, newProbRaw) => {
117
+ setFixtureOverrides(prev => {
118
+ const next = { ...prev };
119
+ const matchOverrides = { ...next[matchId] };
120
+
121
+ let newProb = Math.min(Math.max(parseFloat(newProbRaw), 0), 1);
122
+ const oldProb = matchOverrides[gw] || 0;
123
+ let diff = newProb - oldProb;
124
+
125
+ // Find all OTHER gameweeks that are already fully configured (ignoring blanks)
126
+ const otherGws = Object.keys(matchOverrides).filter(k => k !== String(gw) && !k.startsWith('unselected'));
127
+
128
+ if (otherGws.length > 0 && diff !== 0) {
129
+ if (otherGws.length === 1) {
130
+ // If 2 total GWs, modifying one perfectly scales the other
131
+ let otherProb = matchOverrides[otherGws[0]] - diff;
132
+ otherProb = Math.min(Math.max(otherProb, 0), 1);
133
+ matchOverrides[otherGws[0]] = otherProb;
134
+ newProb = 1 - otherProb;
135
+ } else {
136
+ // If 3+ GWs, distribute the remainder proportionally
137
+ let sumOthers = otherGws.reduce((acc, key) => acc + matchOverrides[key], 0);
138
+ if (sumOthers === 0) {
139
+ matchOverrides[otherGws[0]] = 1 - newProb;
140
+ } else {
141
+ const targetOthersSum = 1 - newProb;
142
+ otherGws.forEach(k => {
143
+ matchOverrides[k] = (matchOverrides[k] / sumOthers) * targetOthersSum;
144
+ });
145
+ }
146
+ }
147
+ }
148
+
149
+ matchOverrides[gw] = newProb;
150
+ next[matchId] = matchOverrides;
151
+ return next;
152
+ });
153
+ };
154
+
155
+ // Deleting a row gives its probability to the remaining rows
156
+ const handleRemoveSplit = (matchId, gw) => {
157
+ setFixtureOverrides(prev => {
158
+ const next = { ...prev };
159
+ const matchOverrides = { ...next[matchId] };
160
+ const deletedProb = matchOverrides[gw] || 0;
161
+ delete matchOverrides[gw];
162
+
163
+ const remaining = Object.keys(matchOverrides).filter(k => !k.startsWith('unselected'));
164
+ if (remaining.length > 0 && deletedProb > 0) {
165
+ if (remaining.length === 1) {
166
+ matchOverrides[remaining[0]] += deletedProb;
167
+ } else {
168
+ let sumRem = remaining.reduce((a, k) => a + matchOverrides[k], 0);
169
+ if(sumRem === 0) {
170
+ matchOverrides[remaining[0]] = 1.0;
171
+ } else {
172
+ remaining.forEach(k => {
173
+ matchOverrides[k] += (matchOverrides[k] / sumRem) * deletedProb;
174
+ });
175
+ }
176
+ }
177
+ }
178
+
179
+ if (Object.keys(matchOverrides).length === 0) delete next[matchId];
180
+ else next[matchId] = matchOverrides;
181
+
182
+ return next;
183
+ });
184
+ };
185
+
186
+ const handleRemoveEntireOverride = (matchId) => {
187
+ setFixtureOverrides(prev => {
188
+ const next = { ...prev };
189
+ delete next[matchId];
190
+ return next;
191
+ });
192
+ };
193
+
194
+ return (
195
+ <div className="flex flex-col gap-4 flex-1 h-full">
196
+
197
+ {/* Header */}
198
+ <div className="flex items-center justify-between">
199
+ <p className="text-[10px] text-slate-500 leading-relaxed max-w-[70%]">
200
+ Override schedules & EV splits. Only customized fixtures are displayed below.
201
+ </p>
202
+ {Object.keys(fixtureOverrides).length > 0 && (
203
+ <button onClick={() => { if(window.confirm("Reset all?")) setFixtureOverrides({}); }} className="text-[10px] font-bold text-red-400 hover:text-red-300 uppercase tracking-wider bg-red-500/10 px-2 py-1 rounded transition-colors">
204
+ Reset All
205
+ </button>
206
+ )}
207
+ </div>
208
+
209
+ {/* Fixture Search Bar & Admin Tools */}
210
+ <div className="relative flex gap-2">
211
+ <div className="flex-1 flex items-center bg-slate-900 border border-slate-700 rounded-lg px-3 focus-within:border-indigo-500 transition-colors shadow-inner relative">
212
+ {/* Secret Click Zone */}
213
+ <div className="absolute left-0 w-10 h-full flex items-center justify-center cursor-pointer z-10" onClick={handleSecretClick}>
214
+ <Search size={14} className="text-slate-500 pointer-events-none" />
215
+ </div>
216
+
217
+ <input
218
+ type="text"
219
+ placeholder="Search team to add override..."
220
+ value={search}
221
+ onChange={(e) => setSearch(e.target.value)}
222
+ className="w-full bg-transparent py-2 pl-6 text-xs font-bold text-slate-200 outline-none"
223
+ />
224
+ {search && <button onClick={() => setSearch("")} className="text-slate-500 hover:text-white z-10"><X size={14}/></button>}
225
+ </div>
226
+
227
+ {/* Admin Login UI */}
228
+ {showAdminLogin && !isAdmin && (
229
+ <div className="flex gap-2 animate-in fade-in slide-in-from-left-4 duration-300">
230
+ <input type="password" placeholder="Admin Pass" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && setIsAdmin(true)} className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-xs w-28 outline-none focus:border-orange-500 text-slate-200" />
231
+ <button onClick={() => setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-xs text-white transition-colors">Login</button>
232
+ </div>
233
+ )}
234
+
235
+ {isAdmin && (
236
+ <button onClick={handlePublishGlobal} className="animate-in fade-in zoom-in bg-orange-600 hover:bg-orange-500 text-white font-bold text-xs px-3 py-1.5 rounded shadow-lg transition-colors flex items-center gap-1 ml-2">
237
+ <Database size={12} /> Publish Globally
238
+ </button>
239
+ )}
240
+
241
+ {/* Search Results Dropdown */}
242
+ {search && (
243
+ <div className="absolute top-full left-0 w-full mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-2xl overflow-hidden z-50">
244
+ {searchResults.length === 0 ? (
245
+ <div className="p-3 text-xs text-slate-400 italic">No matches found...</div>
246
+ ) : (
247
+ searchResults.map(match => (
248
+ <button
249
+ key={match.id}
250
+ onClick={() => handleAddOverride(match)}
251
+ className="w-full flex items-center justify-between p-3 border-b border-slate-700/50 hover:bg-slate-700 transition-colors text-left"
252
+ >
253
+ <div className="flex items-center gap-2">
254
+ <span className="text-xs font-black text-slate-200">{match.homeTeam}</span>
255
+ <span className="text-[9px] text-slate-500 font-bold uppercase">vs</span>
256
+ <span className="text-xs font-black text-slate-200">{match.awayTeam}</span>
257
+ </div>
258
+ <span className="text-[10px] font-bold text-slate-400 bg-slate-900 px-2 py-1 rounded">GW{match.defaultGw}</span>
259
+ </button>
260
+ ))
261
+ )}
262
+ </div>
263
+ )}
264
+ </div>
265
+
266
+ {/* Active Overrides List */}
267
+ <div className="flex flex-col gap-3 overflow-y-auto custom-scrollbar pr-2 pb-4">
268
+ {activeSplits.length === 0 ? (
269
+ <div className="flex flex-col items-center justify-center py-10 opacity-40">
270
+ <Zap size={32} className="text-slate-500 mb-2" />
271
+ <span className="text-xs font-bold text-slate-400 uppercase tracking-widest">No Active Overrides</span>
272
+ </div>
273
+ ) : (
274
+ activeSplits.map(match => {
275
+ const overrides = effectiveFixtures[match.id];
276
+
277
+ return (
278
+ <div key={match.id} className="flex flex-col bg-slate-900 border border-indigo-500/50 shadow-[0_0_15px_rgba(99,102,241,0.1)] rounded-xl overflow-hidden">
279
+
280
+ {/* Match Header */}
281
+ <div className="flex items-center justify-between px-3 py-2 bg-slate-950/50 border-b border-indigo-500/20">
282
+ <div className="flex items-center gap-2">
283
+ <span className="text-xs font-black text-slate-300">{match.homeTeam}</span>
284
+ <span className="text-[9px] text-slate-600 font-bold uppercase tracking-widest">vs</span>
285
+ <span className="text-xs font-black text-slate-300">{match.awayTeam}</span>
286
+ </div>
287
+ <button onClick={() => handleRemoveEntireOverride(match.id)} className="text-slate-500 hover:text-red-400 transition-colors" title="Remove Override">
288
+ <X size={14} />
289
+ </button>
290
+ </div>
291
+
292
+ {/* Sliders / Override UI */}
293
+ <div className="p-3 flex flex-col gap-3 bg-indigo-950/10">
294
+ {Object.entries(overrides).map(([gw, prob]) => {
295
+ // Check if this row is an empty placeholder waiting for a selection
296
+ const isUnselected = gw.startsWith('unselected');
297
+
298
+ return (
299
+ <div key={gw} className="flex items-center gap-3">
300
+ <select
301
+ value={isUnselected ? "" : gw}
302
+ onChange={(e) => handleChangeSplitGw(match.id, gw, e.target.value)}
303
+ className={`bg-slate-950 border text-xs font-bold rounded px-1 py-1 outline-none w-[76px] transition-colors ${isUnselected ? 'border-dashed border-indigo-500/80 text-indigo-200' : 'border-indigo-500/30 text-indigo-300'}`}
304
+ >
305
+ {isUnselected && <option value="" disabled>Select GW</option>}
306
+ {availableGWs.map(g => {
307
+ // Don't show gameweeks that are already selected in another row for this match
308
+ const isAlreadySelected = Object.keys(overrides).includes(String(g)) && String(g) !== String(gw);
309
+ return !isAlreadySelected && <option key={g} value={g}>GW{g}</option>;
310
+ })}
311
+ </select>
312
+
313
+ {/* 1% Step Sliders, locked if no GW is selected */}
314
+ <input
315
+ type="range" min="0" max="1" step="0.01"
316
+ value={prob}
317
+ disabled={isUnselected}
318
+ onChange={(e) => handleUpdateSplit(match.id, gw, e.target.value)}
319
+ className={`flex-1 accent-indigo-500 ${isUnselected ? 'opacity-30 cursor-not-allowed' : 'cursor-ew-resize'}`}
320
+ />
321
+
322
+ {/* Editable Number Input with rounding and boundaries */}
323
+ <div className="relative flex items-center">
324
+ <input
325
+ type="number"
326
+ min="0"
327
+ max="100"
328
+ step="1"
329
+ disabled={isUnselected}
330
+ value={Math.round(prob * 100)}
331
+ onChange={(e) => {
332
+ let val = Math.round(Number(e.target.value));
333
+ if (isNaN(val)) val = 0;
334
+ if (val > 100) val = 100;
335
+ if (val < 0) val = 0;
336
+ handleUpdateSplit(match.id, gw, val / 100);
337
+ }}
338
+ className={`w-14 bg-slate-900 border border-indigo-500/30 text-indigo-300 text-xs font-bold rounded px-1 py-1 outline-none text-right pr-4 transition-colors ${isUnselected ? 'opacity-30 cursor-not-allowed' : 'hover:border-indigo-500/80 focus:border-indigo-400'}`}
339
+ />
340
+ <span className={`absolute right-1.5 text-[10px] font-bold text-indigo-400 pointer-events-none ${isUnselected ? 'opacity-30' : ''}`}>%</span>
341
+ </div>
342
+
343
+ <button onClick={() => handleRemoveSplit(match.id, gw)} className="text-slate-600 hover:text-red-400 p-1">
344
+ <Trash2 size={14} />
345
+ </button>
346
+ </div>
347
+ );
348
+ })}
349
+
350
+ {/* Footer: Add Row & EV Sum Validator */}
351
+ <div className="flex justify-between items-center mt-1 border-t border-indigo-500/20 pt-2">
352
+ <button onClick={() => handleAddSplitGw(match.id)} className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-indigo-400 hover:text-indigo-300 transition-colors bg-indigo-900/30 px-2 py-1 rounded">
353
+ <Plus size={12} /> Add GW Split
354
+ </button>
355
+
356
+ {(() => {
357
+ const totalProb = Object.values(overrides).reduce((a, b) => a + b, 0);
358
+ const isBalanced = Math.abs(totalProb - 1.0) < 0.01;
359
+ return (
360
+ <span className={`text-[9px] font-bold uppercase tracking-wider flex items-center gap-1 ${isBalanced ? 'text-emerald-500' : 'text-red-500'}`}>
361
+ {isBalanced ? <Zap size={10} /> : null}
362
+ Total: {Math.round(totalProb * 100)}%
363
+ </span>
364
+ );
365
+ })()}
366
+ </div>
367
+ </div>
368
+ </div>
369
+ );
370
+ })
371
+ )}
372
+ </div>
373
+ </div>
374
+ );
375
+ };
frontend/src/components/Fixtures.jsx ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useContext } from 'react';
2
+ import { getShortName } from '../utils/teams';
3
+ import { PlayerContext } from '../PlayerContext';
4
+
5
+ export default function Fixtures() {
6
+ const [fixtures, setFixtures] = useState([]);
7
+ const [isLoading, setIsLoading] = useState(true);
8
+
9
+ useEffect(() => {
10
+ fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures')
11
+ .then(res => res.json())
12
+ .then(data => {
13
+ setFixtures(data);
14
+ setIsLoading(false);
15
+ });
16
+ }, []);
17
+
18
+ if (isLoading) return <div className="text-luigi-400 animate-pulse p-8">Loading fixtures...</div>;
19
+
20
+ const { effectiveFixtures } = useContext(PlayerContext);
21
+
22
+ const TEAM_MAP = {
23
+ "Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "AFC Bournemouth": 4, "Brentford": 5,
24
+ "Brighton": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10,
25
+ "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13,
26
+ "Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15,
27
+ "Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17,
28
+ "Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18,
29
+ "West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20
30
+ };
31
+
32
+ const expandedFixtures = [];
33
+
34
+ fixtures.forEach(match => {
35
+ const hId = match.home_team_id || TEAM_MAP[match.home_team] || match.home_team;
36
+ const aId = match.away_team_id || TEAM_MAP[match.away_team] || match.away_team;
37
+ const matchId = `${hId}_vs_${aId}`;
38
+
39
+ const override = effectiveFixtures?.[matchId];
40
+
41
+ if (override) {
42
+ Object.entries(override).forEach(([gw, prob]) => {
43
+ // THE FIX: Prevent floating point ghost fixtures (must be >= 0.5%)
44
+ if (Number(prob) >= 0.005) {
45
+ expandedFixtures.push({ ...match, GW: Number(gw), shiftProb: Number(prob) });
46
+ }
47
+ });
48
+ } else {
49
+ expandedFixtures.push({ ...match, shiftProb: 1.0 });
50
+ }
51
+ });
52
+
53
+ const groupedFixtures = expandedFixtures.reduce((acc, match) => {
54
+ (acc[match.GW] = acc[match.GW] || []).push(match);
55
+ return acc;
56
+ }, {});
57
+
58
+ return (
59
+ <div className="space-y-8">
60
+ {Object.entries(groupedFixtures).map(([gw, matches]) => (
61
+ <div key={gw} className="bg-slate-900/40 p-6 rounded-xl border border-slate-800 backdrop-blur-sm shadow-xl">
62
+ <h3 className="text-xl font-bold text-luigi-400 mb-4 border-b border-slate-800 pb-2">Gameweek {gw}</h3>
63
+
64
+ <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
65
+ {matches.map((match, idx) => {
66
+
67
+ const hw = match.home_win_prob;
68
+ const aw = match.away_win_prob;
69
+ const isGhost = match.shiftProb < 0.995;
70
+
71
+ const homeBox = hw > aw ? 'text-emerald-400 bg-emerald-900/30' : (hw < aw ? 'text-rose-400 bg-rose-900/30' : 'text-slate-300 bg-slate-800/50');
72
+ const awayBox = aw > hw ? 'text-emerald-400 bg-emerald-900/30' : (aw < hw ? 'text-rose-400 bg-rose-900/30' : 'text-slate-300 bg-slate-800/50');
73
+
74
+ const ghostStyles = isGhost
75
+ ? "border-dashed border-indigo-500/50 opacity-80 bg-[repeating-linear-gradient(45deg,transparent,transparent_10px,rgba(99,102,241,0.05)_10px,rgba(99,102,241,0.05)_20px)]"
76
+ : "border-slate-800/80 hover:border-slate-600";
77
+
78
+ return (
79
+ <div
80
+ key={`${idx}-${match.shiftProb}`}
81
+ title={isGhost ? `${Math.round(match.shiftProb * 100)}% chance of playing in GW${gw}` : `Confirmed Fixture`}
82
+ className={`relative bg-slate-950 rounded-lg border overflow-hidden shadow-lg transition-colors ${ghostStyles}`}
83
+ >
84
+ {isGhost && (
85
+ <div className="absolute top-2 right-2 bg-indigo-900/80 text-indigo-300 text-[10px] font-black px-2 py-0.5 rounded border border-indigo-500/50 backdrop-blur-md z-10 shadow-lg">
86
+ {Math.round(match.shiftProb * 100)}% Chance
87
+ </div>
88
+ )}
89
+
90
+ {/* Header: Teams & xG */}
91
+ <div className="bg-slate-900/80 px-4 py-3 flex justify-between items-center border-b border-slate-800">
92
+ <div className="flex flex-col items-center w-1/3">
93
+ <span className="text-lg font-bold text-slate-100">{getShortName(match.home_team)}</span>
94
+ {/* UPDATED: Larger, bolder xG text */}
95
+ <span className="text-base font-mono font-bold text-slate-300 mt-1">
96
+ {match.expected_home_goals.toFixed(2)} xG
97
+ </span>
98
+ </div>
99
+
100
+ <span className="text-slate-600 text-xs font-bold uppercase tracking-widest bg-slate-950 px-2 py-1 rounded-full border border-slate-800">vs</span>
101
+
102
+ <div className="flex flex-col items-center w-1/3">
103
+ <span className="text-lg font-bold text-slate-100">{getShortName(match.away_team)}</span>
104
+ {/* UPDATED: Larger, bolder xG text */}
105
+ <span className="text-base font-mono font-bold text-slate-300 mt-1">
106
+ {match.expected_away_goals.toFixed(2)} xG
107
+ </span>
108
+ </div>
109
+ </div>
110
+
111
+ {/* Body: Probabilities & Clean Sheets */}
112
+ <div className="p-3">
113
+ <div className="flex justify-between text-[10px] text-slate-400 font-mono text-center mb-3">
114
+ <div className="flex flex-col w-[30%]">
115
+ <span className="mb-1">HOME WIN</span>
116
+ <span className={`text-sm py-1 rounded font-bold ${homeBox}`}>{(match.home_win_prob * 100).toFixed(1)}%</span>
117
+ </div>
118
+ <div className="flex flex-col w-[30%]">
119
+ <span className="mb-1">DRAW</span>
120
+ <span className="text-slate-300 text-sm bg-slate-800/30 py-1 rounded font-bold">{(match.draw_prob * 100).toFixed(1)}%</span>
121
+ </div>
122
+ <div className="flex flex-col w-[30%]">
123
+ <span className="mb-1">AWAY WIN</span>
124
+ <span className={`text-sm py-1 rounded font-bold ${awayBox}`}>{(match.away_win_prob * 100).toFixed(1)}%</span>
125
+ </div>
126
+ </div>
127
+
128
+ {/* Clean Sheet Odds */}
129
+ <div className="flex justify-between border-t border-slate-800/50 pt-2 text-xs">
130
+ <div className="text-slate-400">Home CS: <span className="text-luigi-400 font-mono font-bold">{(match.home_clean_sheet_odds * 100).toFixed(1)}%</span></div>
131
+ <div className="text-slate-400">Away CS: <span className="text-luigi-400 font-mono font-bold">{(match.away_clean_sheet_odds * 100).toFixed(1)}%</span></div>
132
+ </div>
133
+ </div>
134
+
135
+ </div>
136
+ );
137
+ })}
138
+ </div>
139
+ </div>
140
+ ))}
141
+ </div>
142
+ );
143
+ }
frontend/src/components/LandingPage.jsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from "react";
2
+ import { Target, TrendingUp, Shield } from "lucide-react";
3
+ import LoginModal from "./LoginModal"; // Fixed import syntax
4
+
5
+ // Adjust these paths depending on exactly where your images are saved in your src folder!
6
+
7
+ export const LandingPage = () => {
8
+ const [showLoginModal, setShowLoginModal] = useState(false);
9
+
10
+ return (
11
+ <div className="min-h-screen flex flex-col items-center justify-center relative overflow-hidden bg-slate-950">
12
+
13
+ {/* Epic Mansion Background */}
14
+ <div
15
+ className="absolute inset-0 z-0 bg-cover bg-center bg-no-repeat opacity-40 mix-blend-luminosity"
16
+ style={{ backgroundImage: `url(/luigismansion.jpg)` }}
17
+ />
18
+
19
+ {/* Gradient Overlay to ensure text remains highly readable */}
20
+ <div className="absolute inset-0 z-0 bg-gradient-to-b from-slate-950/90 via-slate-900/60 to-slate-950/90" />
21
+
22
+ <div className="z-10 flex flex-col items-center text-center px-4 max-w-3xl mt-[-5vh]">
23
+
24
+ {/* Glowing Luigi Orb */}
25
+ <div className="w-28 h-28 bg-slate-900/80 border-2 border-luigi-500/50 rounded-full flex items-center justify-center mb-6 shadow-[0_0_40px_rgba(16,185,129,0.5)] backdrop-blur-md p-3 transition-transform hover:scale-105 duration-500">
26
+ <img src="/icon.jpg" alt="Luigi's Mansion" className="w-full h-full object-contain drop-shadow-2xl" />
27
+ </div>
28
+
29
+ <h1 className="text-6xl md:text-8xl font-black text-white mb-6 tracking-tighter drop-shadow-2xl">
30
+ Welcome to <br />
31
+ <span className="text-transparent bg-clip-text bg-gradient-to-r from-luigi-400 via-emerald-400 to-luigi-600 drop-shadow-[0_0_20px_rgba(16,185,129,0.6)]">
32
+ Luigi's Mansion
33
+ </span>
34
+ </h1>
35
+
36
+ <p className="text-xl md:text-2xl font-bold text-slate-300 mb-12 max-w-2xl leading-relaxed drop-shadow-lg">
37
+ Luigi's Mansion is here. New, improved, and simply Wieffertastic.
38
+ </p>
39
+
40
+ <button
41
+ onClick={() => setShowLoginModal(true)}
42
+ className="px-10 py-5 bg-gradient-to-r from-luigi-600 to-emerald-800 hover:from-luigi-500 hover:to-emerald-600 text-white font-black rounded-xl text-xl transition-all shadow-[0_0_30px_rgba(16,185,129,0.4)] hover:shadow-[0_0_50px_rgba(16,185,129,0.8)] hover:-translate-y-1 uppercase tracking-widest border border-luigi-400/50"
43
+ >
44
+ Enter the Mansion
45
+ </button>
46
+
47
+ {/* Feature Highlights */}
48
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 mt-20 text-left w-full max-w-4xl relative z-10">
49
+ <div className="bg-slate-950/60 backdrop-blur-md border border-slate-800/80 p-5 rounded-2xl hover:border-luigi-500/30 transition-colors">
50
+ <Target className="text-luigi-400 mb-3" size={24} />
51
+ <h3 className="text-slate-200 font-bold mb-1">In-built Solver</h3>
52
+ <p className="text-slate-400 text-sm">With a horizon of 10 gameweeks and 5 drafts to allow you to solve for different scenarios.</p>
53
+ </div>
54
+ <div className="bg-slate-950/60 backdrop-blur-md border border-slate-800/80 p-5 rounded-2xl hover:border-cyan-500/30 transition-colors">
55
+ <TrendingUp className="text-cyan-400 mb-3" size={24} />
56
+ <h3 className="text-slate-200 font-bold mb-1">Editable Projections</h3>
57
+ <p className="text-slate-400 text-sm">Real-time projections adjustment based on your xMins inputs. </p>
58
+ </div>
59
+ <div className="bg-slate-950/60 backdrop-blur-md border border-slate-800/80 p-5 rounded-2xl hover:border-purple-500/30 transition-colors">
60
+ <Shield className="text-purple-400 mb-3" size={24} />
61
+ <h3 className="text-slate-200 font-bold mb-1">Cloud Synced</h3>
62
+ <p className="text-slate-400 text-sm">Your squad, manual edits, and settings are securely saved to your account.</p>
63
+ </div>
64
+ </div>
65
+ </div>
66
+
67
+ {/* Render the actual Login Modal safely on top of EVERYTHING */}
68
+ {showLoginModal && (
69
+ <div className="fixed inset-0 z-[9999] flex items-center justify-center">
70
+ {/* THE FIX: We must explicitly pass isOpen={true} to bypass the modal's internal null check */}
71
+ <LoginModal isOpen={true} onClose={() => setShowLoginModal(false)} />
72
+ </div>
73
+ )}
74
+ </div>
75
+ );
76
+ };
frontend/src/components/LoginModal.jsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useContext } from 'react';
2
+ import { X, Mail, Lock, Loader2, Shield, Eye, EyeOff } from 'lucide-react';
3
+ import { PlayerContext } from '../PlayerContext';
4
+ import { GoogleLogin } from '@react-oauth/google';
5
+
6
+ export default function LoginModal({ isOpen, onClose }) {
7
+ const { setIsLoggedIn, setUserProfile, setHasGuestMadeEdits } = useContext(PlayerContext);
8
+
9
+ const [isSignUp, setIsSignUp] = useState(false);
10
+ const [email, setEmail] = useState('');
11
+ const [password, setPassword] = useState('');
12
+ const [confirmPassword, setConfirmPassword] = useState('');
13
+
14
+ const [showPassword, setShowPassword] = useState(false);
15
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
16
+
17
+ const [isLoading, setIsLoading] = useState(false);
18
+ const [error, setError] = useState('');
19
+
20
+ if (!isOpen) return null;
21
+
22
+ const toggleMode = () => {
23
+ setIsSignUp(!isSignUp);
24
+ setError('');
25
+ setPassword('');
26
+ setConfirmPassword('');
27
+ };
28
+
29
+ const handleSubmit = async (e) => {
30
+ e.preventDefault();
31
+ setIsLoading(true);
32
+ setError('');
33
+
34
+ // Reconfirmation Validation for Sign Up
35
+ if (isSignUp && password !== confirmPassword) {
36
+ setError('Passwords do not match!');
37
+ setIsLoading(false);
38
+ return;
39
+ }
40
+
41
+ const endpoint = isSignUp ? '/api/auth/register' : '/api/auth/login';
42
+
43
+ try {
44
+ const res = await fetch(`https://anayshukla-fpl-solver.hf.space${endpoint}`, {
45
+ method: 'POST',
46
+ headers: { 'Content-Type': 'application/json' },
47
+ body: JSON.stringify({ email, password })
48
+ });
49
+
50
+ const data = await res.json();
51
+
52
+ if (!res.ok) {
53
+ throw new Error(data.detail || 'Authentication failed');
54
+ }
55
+
56
+ // Success! Save token and update Global Context
57
+ localStorage.setItem('fpl_token', data.access_token);
58
+
59
+ setUserProfile({
60
+ username: data.email.split('@')[0],
61
+ defaultTeamId: null,
62
+ isAdmin: data.is_admin
63
+ });
64
+
65
+ setIsLoggedIn(true);
66
+ setHasGuestMadeEdits(false);
67
+ onClose();
68
+
69
+ } catch (err) {
70
+ setError(err.message);
71
+ } finally {
72
+ setIsLoading(false);
73
+ }
74
+ };
75
+
76
+ return (
77
+ <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
78
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200">
79
+
80
+ {/* Header */}
81
+ <div className="bg-slate-900 p-5 flex justify-between items-center border-b border-slate-800">
82
+ <div className="flex items-center gap-3">
83
+ <div className="w-8 h-8 bg-luigi-500/20 text-luigi-400 rounded-lg flex items-center justify-center">
84
+ <Shield size={18} />
85
+ </div>
86
+ <h2 className="text-xl font-black text-slate-100">
87
+ {isSignUp ? 'Create Account' : 'Welcome Back'}
88
+ </h2>
89
+ </div>
90
+ <button onClick={onClose} className="text-slate-500 hover:text-white transition-colors bg-slate-950 p-1.5 rounded-full border border-slate-800">
91
+ <X size={18} />
92
+ </button>
93
+ </div>
94
+
95
+ {/* Body */}
96
+ <div className="p-6">
97
+ {error && (
98
+ <div className="mb-4 p-3 bg-red-950/30 border border-red-900/50 text-red-400 text-sm rounded-lg text-center font-bold">
99
+ {error}
100
+ </div>
101
+ )}
102
+
103
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
104
+ {/* Email Field */}
105
+ <div className="relative">
106
+ <Mail className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
107
+ <input
108
+ type="email"
109
+ required
110
+ placeholder="Email Address"
111
+ value={email}
112
+ onChange={(e) => setEmail(e.target.value)}
113
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl py-3 pl-10 pr-4 text-sm text-slate-200 focus:outline-none focus:border-luigi-400 transition-colors"
114
+ />
115
+ </div>
116
+
117
+ {/* Password Field */}
118
+ <div className="relative">
119
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
120
+ <input
121
+ type={showPassword ? "text" : "password"}
122
+ required
123
+ placeholder="Password"
124
+ value={password}
125
+ onChange={(e) => setPassword(e.target.value)}
126
+ className="w-full bg-slate-900 border border-slate-700 rounded-xl py-3 pl-10 pr-10 text-sm text-slate-200 focus:outline-none focus:border-luigi-400 transition-colors"
127
+ />
128
+ <button
129
+ type="button"
130
+ onClick={() => setShowPassword(!showPassword)}
131
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
132
+ >
133
+ {showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
134
+ </button>
135
+ </div>
136
+
137
+ {/* Confirm Password Field (Only for Sign Up) */}
138
+ {isSignUp && (
139
+ <div className="relative animate-in slide-in-from-top-2 fade-in duration-200">
140
+ <Lock className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={16} />
141
+ <input
142
+ type={showConfirmPassword ? "text" : "password"}
143
+ required
144
+ placeholder="Confirm Password"
145
+ value={confirmPassword}
146
+ onChange={(e) => setConfirmPassword(e.target.value)}
147
+ className={`w-full bg-slate-900 border rounded-xl py-3 pl-10 pr-10 text-sm text-slate-200 focus:outline-none transition-colors ${
148
+ confirmPassword && password !== confirmPassword
149
+ ? "border-red-500/50 focus:border-red-500"
150
+ : "border-slate-700 focus:border-luigi-400"
151
+ }`}
152
+ />
153
+ <button
154
+ type="button"
155
+ onClick={() => setShowConfirmPassword(!showConfirmPassword)}
156
+ className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
157
+ >
158
+ {showConfirmPassword ? <EyeOff size={16} /> : <Eye size={16} />}
159
+ </button>
160
+ </div>
161
+ )}
162
+
163
+ <button
164
+ type="submit"
165
+ disabled={isLoading}
166
+ className="w-full bg-luigi-500 hover:bg-luigi-400 text-slate-950 py-3 rounded-xl font-bold text-sm transition-colors shadow-lg shadow-luigi-500/20 flex justify-center items-center mt-2"
167
+ >
168
+ {isLoading ? <Loader2 size={18} className="animate-spin" /> : (isSignUp ? 'Create Account' : 'Log In')}
169
+ </button>
170
+ </form>
171
+
172
+ {/* Toggle Login/Signup Mode */}
173
+ <div className="mt-6 flex items-center justify-between text-sm text-slate-500">
174
+ <span>{isSignUp ? 'Already have an account?' : "Don't have an account?"}</span>
175
+ <button
176
+ onClick={toggleMode}
177
+ className="text-luigi-400 font-bold hover:underline"
178
+ >
179
+ {isSignUp ? 'Log In' : 'Sign Up'}
180
+ </button>
181
+ </div>
182
+
183
+ {/* Elegant "OR" Divider */}
184
+ <div className="mt-6 mb-2 relative flex items-center justify-center">
185
+ <div className="absolute inset-0 flex items-center">
186
+ <div className="w-full border-t border-slate-800"></div>
187
+ </div>
188
+ <div className="relative px-4 bg-slate-950 text-xs font-bold text-slate-500 uppercase tracking-widest">
189
+ OR
190
+ </div>
191
+ </div>
192
+
193
+ {/* Google Login Block */}
194
+ <div className="mt-4 flex justify-center">
195
+ <GoogleLogin
196
+ text={isSignUp ? "signup_with" : "signin_with"}
197
+ onSuccess={async (credentialResponse) => {
198
+ try {
199
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/auth/google', {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json' },
202
+ body: JSON.stringify({ token: credentialResponse.credential })
203
+ });
204
+ const data = await res.json();
205
+ if (!res.ok) throw new Error(data.detail || "Google Auth Failed");
206
+
207
+ localStorage.setItem('fpl_token', data.access_token);
208
+ setUserProfile({
209
+ username: data.email.split('@')[0],
210
+ defaultTeamId: null,
211
+ isAdmin: data.is_admin
212
+ });
213
+ setIsLoggedIn(true);
214
+ setHasGuestMadeEdits(false);
215
+ onClose();
216
+ } catch (err) {
217
+ setError(err.message);
218
+ }
219
+ }}
220
+ onError={() => {
221
+ setError('Google Login window closed or failed.');
222
+ }}
223
+ theme="filled_black"
224
+ shape="pill"
225
+ />
226
+ </div>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ );
231
+ }
frontend/src/components/PitchView.jsx ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/components/PitchView.jsx
2
+ import React from "react";
3
+ import { DraggablePlayer } from "./DraggablePlayer";
4
+
5
+ export const PitchView = ({
6
+ teamData,
7
+ activeDragPlayer,
8
+ isValidSwap,
9
+ captainId,
10
+ viceId,
11
+ handleCapChange,
12
+ playerCardGWs,
13
+ fixtures,
14
+ activeGW,
15
+ setSelectedPlayer,
16
+ handleUndoTransfer,
17
+ highlightTransferIds,
18
+ solverTransferPairs,
19
+ resetHighlightedTransfer,
20
+ chipsByGw,
21
+ }) => {
22
+ return (
23
+ <div className="w-full bg-[#0a3a2a] rounded-2xl border-4 border-[#072a1e] min-h-[650px] xl:min-h-[850px] relative overflow-hidden flex flex-col shadow-[0_0_50px_rgba(0,0,0,0.5)]">
24
+ {/* PITCH LINES */}
25
+ <div className="absolute inset-0 pointer-events-none opacity-30 flex flex-col items-center">
26
+ <div className="w-full h-1/2 border-b-2 border-white/40 absolute top-0"></div>
27
+ <div className="w-48 h-48 sm:w-64 sm:h-64 border-2 border-white/40 rounded-full absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
28
+ <div className="w-2 h-2 bg-white/40 rounded-full absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2"></div>
29
+ <div className="w-[50%] sm:w-[40%] h-[15%] border-2 border-white/40 absolute top-0 left-1/2 -translate-x-1/2 border-t-0"></div>
30
+ <div className="w-[20%] sm:w-[15%] h-[5%] border-2 border-white/40 absolute top-0 left-1/2 -translate-x-1/2 border-t-0"></div>
31
+ <div className="w-[50%] sm:w-[40%] h-[15%] border-2 border-white/40 absolute bottom-0 left-1/2 -translate-x-1/2 border-b-0"></div>
32
+ <div className="w-[20%] sm:w-[15%] h-[5%] border-2 border-white/40 absolute bottom-0 left-1/2 -translate-x-1/2 border-b-0"></div>
33
+ </div>
34
+
35
+ {/* STARTERS */}
36
+ <div className="flex-1 w-full overflow-x-auto custom-scrollbar">
37
+ <div className="flex flex-col justify-evenly gap-y-10 z-10 pt-8 pb-12 px-4 min-w-max sm:min-w-0 min-h-full h-full mx-auto">
38
+ {["G", "D", "M", "F"].map((pos) => {
39
+ const rowPlayers = teamData.slice(0, 11).filter((p) => p.Pos === pos);
40
+ if (rowPlayers.length === 0) return null;
41
+ return (
42
+ <div key={pos} className="flex justify-center gap-[26px] sm:gap-8 md:gap-12 xl:gap-16 w-full px-2">
43
+ {rowPlayers.map((p) => (
44
+ <DraggablePlayer
45
+ key={p.ID}
46
+ player={p}
47
+ isBench={false}
48
+ isActiveDrag={activeDragPlayer !== null}
49
+ isValidTarget={isValidSwap(activeDragPlayer, p)}
50
+ captainId={captainId}
51
+ viceId={viceId}
52
+ handleCapChange={handleCapChange}
53
+ playerCardGWs={playerCardGWs}
54
+ fixtures={fixtures}
55
+ activeGW={activeGW}
56
+ onPlayerClick={(player) => setSelectedPlayer(player)}
57
+ onUndo={handleUndoTransfer}
58
+ isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
59
+ onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
60
+ activeChipType={chipsByGw[activeGW]}
61
+ />
62
+ ))}
63
+ </div>
64
+ );
65
+ })}
66
+ </div>
67
+ </div>
68
+
69
+ {/* BENCH */}
70
+ <div
71
+ className={`mt-auto w-full border-t-2 z-10 transition-colors duration-300 overflow-x-auto custom-scrollbar ${chipsByGw[activeGW] === "bb" ? "bg-emerald-950/60 border-emerald-500/50 shadow-[0_0_24px_rgba(16,185,129,0.2)]" : "bg-slate-950/90 border-slate-800"
72
+ }`}
73
+ >
74
+ <div className="min-h-[140px] sm:min-h-[160px] md:min-h-[180px] min-w-max sm:min-w-0 mx-auto flex justify-center items-center gap-[26px] sm:gap-8 md:gap-12 xl:gap-16 pb-10 pt-6 px-4">
75
+ {teamData.slice(11, 15).map((p, benchIndex) => (
76
+ <DraggablePlayer
77
+ key={p.ID}
78
+ player={p}
79
+ isBench={true}
80
+ benchIndex={benchIndex}
81
+ isActiveDrag={activeDragPlayer !== null}
82
+ isValidTarget={isValidSwap(activeDragPlayer, p)}
83
+ captainId={captainId}
84
+ viceId={viceId}
85
+ handleCapChange={handleCapChange}
86
+ playerCardGWs={playerCardGWs}
87
+ fixtures={fixtures}
88
+ activeGW={activeGW}
89
+ onPlayerClick={(player) => setSelectedPlayer(player)}
90
+ onUndo={handleUndoTransfer}
91
+ isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
92
+ onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
93
+ activeChipType={chipsByGw[activeGW]}
94
+ />
95
+ ))}
96
+ </div>
97
+ </div>
98
+ </div>
99
+ );
100
+ };
frontend/src/components/PlayerCardVisual.jsx ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useContext } from "react";
2
+ import { Plus, RotateCcw } from "lucide-react";
3
+ import { getShortName } from "../utils/teams";
4
+ import { getPlayerPrice } from "../utils/fplLogic";
5
+ import { PlayerContext } from "../PlayerContext";
6
+
7
+ export const PlayerCardVisual = ({
8
+ player,
9
+ isBench,
10
+ captainId,
11
+ viceId,
12
+ handleCapChange,
13
+ playerCardGWs,
14
+ fixtures,
15
+ activeGW,
16
+ onPlayerClick,
17
+ onUndo,
18
+ onSolverUndo,
19
+ activeChipType,
20
+ }) => {
21
+ if (player.isBlank) {
22
+ return (
23
+ <div
24
+ onClick={onPlayerClick}
25
+ className="relative w-[64px] sm:w-[76px] md:w-[88px] h-[90px] sm:h-[105px] md:h-[120px] flex flex-col items-center justify-center cursor-pointer border-2 border-dashed border-slate-500 bg-slate-900/60 rounded-xl hover:bg-slate-800 hover:border-emerald-400 transition-all z-20 shadow-inner group"
26
+ >
27
+ {player.replacedPlayer && (
28
+ <div className="absolute left-[-14px] top-[-10px] flex flex-col gap-1 z-40 pointer-events-auto">
29
+ <button
30
+ onPointerDown={(e) => e.stopPropagation()}
31
+ onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
32
+ className="w-6 h-6 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white shadow-[0_0_10px_rgba(220,38,38,0.5)] transition-colors border border-red-400"
33
+ title="Undo Transfer"
34
+ >
35
+ <RotateCcw size={12} strokeWidth={3} />
36
+ </button>
37
+ </div>
38
+ )}
39
+ <Plus
40
+ className="text-slate-500 group-hover:text-emerald-400 transition-colors mb-1"
41
+ size={24}
42
+ />
43
+ <span className="text-[10px] font-black text-slate-500 group-hover:text-emerald-400 uppercase tracking-widest">
44
+ {player.Pos}
45
+ </span>
46
+ </div>
47
+ );
48
+ }
49
+
50
+ const isCap = player.ID === captainId;
51
+ const isVice = player.ID === viceId;
52
+ const photoUrl = player.photo
53
+ ? `https://resources.premierleague.com/premierleague25/photos/players/110x140/${player.photo.replace(".jpg", ".png")}`
54
+ : "";
55
+
56
+ const { effectiveFixtures } = useContext(PlayerContext) || {};
57
+
58
+ const TEAM_MAP = {
59
+ "Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "Bournemouth": 4, "AFC Bournemouth": 4, "Brentford": 5,
60
+ "Brighton": 6, "Brighton and Hove Albion": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10,
61
+ "Leeds": 11, "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13,
62
+ "Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15,
63
+ "Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17,
64
+ "Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18,
65
+ "West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20
66
+ };
67
+
68
+ const getActiveMatches = (teamName, gw) => {
69
+ if (!fixtures || !fixtures.length || !gw) return [];
70
+ const activeMatches = [];
71
+ fixtures.forEach(m => {
72
+ if (m.home_team !== teamName && m.away_team !== teamName) return;
73
+
74
+ const hId = m.home_team_id || TEAM_MAP[m.home_team] || m.home_team;
75
+ const aId = m.away_team_id || TEAM_MAP[m.away_team] || m.away_team;
76
+ const matchId = `${hId}_vs_${aId}`;
77
+
78
+ const override = effectiveFixtures?.[matchId];
79
+
80
+ if (override) {
81
+ if (Number(override[gw]) >= 0.01) activeMatches.push({ ...m, prob: Number(override[gw]) });
82
+ } else if (String(m.GW) === String(gw)) {
83
+ activeMatches.push({ ...m, prob: 1.0 });
84
+ }
85
+ });
86
+ return activeMatches;
87
+ };
88
+
89
+ const currentGwMatches = getActiveMatches(player.Team, activeGW);
90
+ const isBlankThisGw = currentGwMatches.length === 0;
91
+
92
+ const renderFixtures = (teamName, gw) => {
93
+ const activeMatches = gw === activeGW ? currentGwMatches : getActiveMatches(teamName, gw);
94
+
95
+ if (activeMatches.length === 0) {
96
+ return <span className="text-[8.5px] font-bold text-slate-600">BLANK</span>;
97
+ }
98
+
99
+ return activeMatches.map((m, idx) => {
100
+ const isHome = m.home_team === teamName;
101
+ const oppName = getShortName(isHome ? m.away_team : m.home_team);
102
+ const loc = isHome ? "H" : "A";
103
+ const isGhost = m.prob < 1;
104
+
105
+ return (
106
+ <React.Fragment key={idx}>
107
+ <span
108
+ title={isGhost ? `${Math.round(m.prob * 100)}% chance of playing in GW${gw}` : undefined}
109
+ className={`inline-flex items-center whitespace-nowrap ${isGhost ? "text-indigo-200 cursor-help" : "text-slate-200"}`}
110
+ >
111
+ {/* Team Name - Scaled down 1px and tightened tracking */}
112
+ <span className="text-[8.5px] sm:text-[9.5px] font-black tracking-tighter leading-none">
113
+ {oppName}
114
+ </span>
115
+
116
+ {/* Location - Scaled down 1px */}
117
+ <span className={`text-[7.5px] sm:text-[8.5px] font-bold ml-[1.5px] leading-none ${isGhost ? "text-indigo-400" : "text-slate-400"}`}>
118
+ ({loc})
119
+ </span>
120
+
121
+ {/* Percentage Pill - Scaled down, tighter internal padding */}
122
+ {isGhost && (
123
+ <span
124
+ className="text-[6.5px] sm:text-[7.5px] font-black text-indigo-100 ml-[2px] bg-indigo-500/50 px-[3px] py-[1px] rounded-[2px] border border-indigo-400/40 tracking-tighter shadow-sm flex items-center justify-center"
125
+ style={{ lineHeight: 1 }}
126
+ >
127
+ {Math.round(m.prob * 100)}%
128
+ </span>
129
+ )}
130
+ </span>
131
+
132
+ {/* Divider - Margins shrunk from mx-[5px] to mx-[3px] */}
133
+ {idx < activeMatches.length - 1 && (
134
+ <span className="text-[7px] text-slate-500 mx-[3px] flex items-center leading-none">•</span>
135
+ )}
136
+ </React.Fragment>
137
+ );
138
+ });
139
+ };
140
+
141
+ const evStyles = [
142
+ "text-emerald-400 text-[15px] sm:text-base font-extrabold",
143
+ "text-emerald-500 text-[12px] sm:text-[13px] font-bold",
144
+ "text-emerald-600 text-[10px] sm:text-[11px] font-semibold",
145
+ ];
146
+
147
+ return (
148
+ <div
149
+ onClick={onPlayerClick}
150
+ className="relative w-[64px] sm:w-[76px] md:w-[88px] h-[90px] sm:h-[105px] md:h-[120px] flex flex-col items-center justify-end cursor-grab active:cursor-grabbing"
151
+ >
152
+ <div className="absolute left-[-14px] top-[-10px] flex flex-col gap-1 z-40 pointer-events-auto">
153
+ {!isBench && handleCapChange && (
154
+ <>
155
+ <button
156
+ onPointerDown={(e) => e.stopPropagation()}
157
+ onClick={(e) => {
158
+ e.stopPropagation();
159
+ handleCapChange(player.ID, "C");
160
+ }}
161
+ className={`w-6 h-6 flex items-center justify-center rounded-full text-[11px] font-bold transition-colors shadow-lg ${isCap
162
+ ? activeChipType === "tc"
163
+ ? "bg-purple-500 text-white border border-purple-300 shadow-[0_0_10px_rgba(168,85,247,0.7)] text-[9px]"
164
+ : "bg-yellow-400 text-slate-900 border border-white"
165
+ : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-yellow-400"
166
+ }`}
167
+ >
168
+ {isCap && activeChipType === "tc" ? "TC" : "C"}
169
+ </button>
170
+ <button
171
+ onPointerDown={(e) => e.stopPropagation()}
172
+ onClick={(e) => {
173
+ e.stopPropagation();
174
+ handleCapChange(player.ID, "V");
175
+ }}
176
+ className={`w-6 h-6 flex items-center justify-center rounded-full text-[11px] font-bold transition-colors shadow-lg ${isVice ? "bg-slate-300 text-slate-900 border border-white" : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-white"}`}
177
+ >
178
+ V
179
+ </button>
180
+ </>
181
+ )}
182
+ {onSolverUndo && (
183
+ <button
184
+ onPointerDown={(e) => e.stopPropagation()}
185
+ onClick={(e) => {
186
+ e.stopPropagation();
187
+ onSolverUndo(player);
188
+ }}
189
+ className="w-6 h-6 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white shadow-[0_0_10px_rgba(220,38,38,0.5)] transition-colors border border-red-400"
190
+ title="Revert solver transfer"
191
+ >
192
+ <RotateCcw size={12} strokeWidth={3} />
193
+ </button>
194
+ )}
195
+ {player.replacedPlayer && (
196
+ <button
197
+ onPointerDown={(e) => e.stopPropagation()}
198
+ onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
199
+ className="w-6 h-6 flex items-center justify-center rounded-full bg-red-600 hover:bg-red-500 text-white shadow-[0_0_10px_rgba(220,38,38,0.5)] transition-colors border border-red-400"
200
+ title="Undo transfer"
201
+ >
202
+ <RotateCcw size={12} strokeWidth={3} />
203
+ </button>
204
+ )}
205
+ </div>
206
+
207
+ <div className="absolute right-[-16px] top-[-5px] flex flex-col items-end z-30 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.8)]">
208
+ {playerCardGWs.map((gw, i) => (
209
+ <span key={gw} className={`${evStyles[i]} leading-tight tabular-nums`}>
210
+ {Number(player[`${gw}_Pts`] || 0).toFixed(2)}
211
+ </span>
212
+ ))}
213
+ </div>
214
+
215
+ {photoUrl ? (
216
+ <img
217
+ src={photoUrl}
218
+ alt={player.Name}
219
+ draggable="false"
220
+ className={`absolute bottom-[10px] w-full h-[85%] object-contain pointer-events-none z-10 drop-shadow-2xl ${isBench ? (activeChipType === "bb" ? "opacity-85" : "opacity-50") : "opacity-100"}`}
221
+ />
222
+ ) : (
223
+ <div
224
+ className={`absolute bottom-[10px] w-[80%] h-[70%] bg-slate-800/50 rounded-t-full z-10 pointer-events-none ${isBench ? (activeChipType === "bb" ? "opacity-85" : "opacity-50") : "opacity-100"}`}
225
+ />
226
+ )}
227
+
228
+ <div
229
+ draggable="false"
230
+ className={`absolute bottom-[-24px] sm:bottom-[-28px] w-[135%] flex flex-col items-center z-30 pointer-events-none ${isBench ? "opacity-80" : "opacity-100"}`}
231
+ >
232
+ <div className="w-full bg-slate-950 border border-slate-700 text-center py-[2px] truncate px-1 font-bold text-[10px] sm:text-[11px] text-slate-100 rounded-t shadow-md">
233
+ {player.Name}
234
+ </div>
235
+ <div className="w-full bg-slate-200 border-x border-slate-700 flex justify-center items-center gap-1.5 sm:gap-2 py-[2.5px] shadow-inner">
236
+ <span className={`text-[10px] sm:text-xs font-black flex items-baseline gap-0.5 ${isBlankThisGw ? 'text-slate-400' : 'text-slate-800'}`}>
237
+ {isBlankThisGw ? "-" : (player[`${activeGW}_xMins`] ?? 90)}{" "}
238
+ <span className="text-[7px] sm:text-[8px] font-bold text-slate-500 uppercase tracking-tight">
239
+ xMins
240
+ </span>
241
+ </span>
242
+ <span className="text-slate-400 font-light text-[10px]">|</span>
243
+ <span className="text-[10px] sm:text-xs font-black text-emerald-700">
244
+ £{getPlayerPrice(player).toFixed(1)}
245
+ </span>
246
+ </div>
247
+ <div
248
+ className="w-full bg-slate-900 border-x border-b border-slate-700 flex items-center rounded-b shadow-md h-[21px] px-0.5 overflow-hidden"
249
+ >
250
+ {/* THE FIX: Standard w-full with justify-center fixes the cutoff bug */}
251
+ <div className="flex items-center justify-center w-full whitespace-nowrap overflow-hidden text-ellipsis">
252
+ {renderFixtures(player.Team, activeGW)}
253
+ </div>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ );
258
+ };
frontend/src/components/PlayerModals.jsx ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // src/components/PlayerModals.jsx
2
+ import React,{ useState, useEffect, useContext } from "react";
3
+ import { Search, Plus } from "lucide-react";
4
+ import { getPlayerPrice } from "../utils/fplLogic";
5
+ import { getShortName } from "../utils/teams";
6
+ import { PlayerContext } from "../PlayerContext";
7
+
8
+ const SafeMinsInput = ({ initialValue, onSave, isChild = false, disabled = false }) => {
9
+ const [val, setVal] = useState(initialValue);
10
+ useEffect(() => setVal(initialValue), [initialValue]);
11
+
12
+ return (
13
+ <input
14
+ type="number"
15
+ disabled={disabled}
16
+ value={val}
17
+ onChange={(e) => {
18
+ setVal(e.target.value);
19
+ onSave(e.target.value);
20
+ }}
21
+ className={isChild
22
+ ? "w-12 bg-slate-950 text-center font-mono text-xs font-bold text-indigo-400 rounded py-1 outline-none focus:ring-1 ring-indigo-500 border border-slate-800"
23
+ : `w-16 text-center font-mono text-sm font-bold rounded py-1 outline-none ${disabled ? 'bg-transparent text-slate-500' : 'bg-slate-900 text-emerald-400 focus:bg-slate-800 focus:ring-1 ring-emerald-500'}`
24
+ }
25
+ />
26
+ );
27
+ };
28
+
29
+ export const PlayerEditModal = ({
30
+ selectedPlayer,
31
+ setSelectedPlayer,
32
+ activeGW,
33
+ horizonGWs,
34
+ updatePlayerStat,
35
+ handleTransferOut,
36
+ fixtures,
37
+ fixtureOverrides,
38
+ sessionEdits,
39
+ globalPlayers
40
+ }) => {
41
+
42
+ const { effectiveFixtures, globalXmins } = useContext(PlayerContext);
43
+ // THE FIX: Grab the live updating player, not the frozen snapshot!
44
+ const livePlayer = globalPlayers?.find(p => p.ID === selectedPlayer.ID) || selectedPlayer;
45
+
46
+ const TEAM_SHORTS = {
47
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
48
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
49
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
50
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
51
+ };
52
+
53
+ return (
54
+ <div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
55
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-200 flex flex-col">
56
+ <div className="bg-slate-900 p-5 flex justify-between items-center border-b border-slate-800">
57
+ <div className="flex flex-col">
58
+ <h3 className="font-black text-2xl text-slate-100 uppercase tracking-tight">{livePlayer.Name}</h3>
59
+ <div className="flex gap-3 text-sm font-bold text-slate-500">
60
+ <span>{livePlayer.Team}</span>
61
+ <span className="text-slate-700">|</span>
62
+ <span className="text-emerald-500">£{getPlayerPrice(livePlayer).toFixed(1)}m</span>
63
+ </div>
64
+ </div>
65
+ <button onClick={() => setSelectedPlayer(null)} className="text-slate-500 hover:text-white transition-colors bg-slate-900 p-2 rounded-full border border-slate-800">✕</button>
66
+ </div>
67
+ <div className="p-6 flex flex-col gap-6">
68
+ <div className="flex gap-4">
69
+ {[
70
+ { label: `GW${activeGW} xG`, val: livePlayer[`${activeGW}_xG`] ?? livePlayer.xG ?? "-" },
71
+ { label: `GW${activeGW} xA`, val: livePlayer[`${activeGW}_xA`] ?? livePlayer.xA ?? "-" },
72
+ { label: `GW${activeGW} CS%`, val: livePlayer[`${activeGW}_CS_Pct`] ?? livePlayer.CS_Pct ?? "-" },
73
+ ]
74
+ .filter(stat => !(stat.label.includes('CS%') && livePlayer.Pos === 'F'))
75
+ .map((stat) => (
76
+ <div key={stat.label} className="flex-1 bg-slate-900 p-3 rounded-xl border border-slate-800 flex flex-col items-center">
77
+ <span className="text-[10px] text-slate-500 font-bold uppercase tracking-widest text-center">{stat.label}</span>
78
+ <span className="text-lg font-mono font-bold text-slate-200">{typeof stat.val === "number" ? stat.val.toFixed(2) : stat.val}</span>
79
+ </div>
80
+ ))}
81
+ </div>
82
+
83
+ <div className="border border-slate-800 rounded-xl overflow-hidden">
84
+ <table className="w-full text-left text-sm">
85
+ <thead className="bg-slate-900 text-xs text-slate-500 uppercase font-bold">
86
+ <tr>
87
+ <th className="p-3">GW</th>
88
+ <th className="p-3 text-center">Fixture</th>
89
+ <th className="p-3 text-center">xMins</th>
90
+ <th className="p-3 text-right">Proj. EV</th>
91
+ </tr>
92
+ </thead>
93
+ <tbody className="divide-y divide-slate-800/50">
94
+ {horizonGWs.map((gw) => {
95
+ const matches = [];
96
+ if (livePlayer.match_projections) {
97
+ Object.entries(livePlayer.match_projections).forEach(([mId, mData]) => {
98
+ // THE FIX 2: Look at the merged globals instead of the empty prop!
99
+ const override = effectiveFixtures?.[mId];
100
+
101
+ // THE FIX 3: Force Number() to prevent API float bugs
102
+ if (override && Number(override[gw]) > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) });
103
+ else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 });
104
+ });
105
+ }
106
+
107
+ const hasMultiple = matches.length > 1;
108
+ const isBlank = matches.length === 0;
109
+
110
+ return (
111
+ <React.Fragment key={gw}>
112
+ <tr className={`transition-colors ${hasMultiple ? 'bg-indigo-950/20' : 'bg-slate-950/50 hover:bg-slate-900'}`}>
113
+ <td className="p-3 font-bold text-slate-400">GW{gw}</td>
114
+ <td className="p-3 text-center text-xs font-bold text-slate-300">
115
+ {isBlank ? (
116
+ "BLANK"
117
+ ) : hasMultiple ? (
118
+ "MULTIPLE"
119
+ ) : (
120
+ <div className="flex items-center justify-center gap-1.5">
121
+ <span>
122
+ {matches[0]?.is_home ? `${TEAM_SHORTS[matches[0].opponent_team_id]} (H)` : `${TEAM_SHORTS[matches[0]?.opponent_team_id]} (A)`}
123
+ </span>
124
+ {matches[0]?.prob < 1.0 && (
125
+ <span className="text-[9px] text-indigo-400 bg-indigo-500/20 px-1.5 py-0.5 rounded border border-indigo-500/30">
126
+ {Math.round(matches[0].prob * 100)}%
127
+ </span>
128
+ )}
129
+ </div>
130
+ )}
131
+ </td>
132
+ <td className="p-3">
133
+ <div className="flex justify-center">
134
+ <SafeMinsInput
135
+ disabled={isBlank}
136
+ initialValue={hasMultiple ? Math.round(livePlayer[`${gw}_xMins`] || 0) : (sessionEdits?.[livePlayer.ID]?.[`${gw}_xMins`] ?? Math.round(livePlayer[`${gw}_xMins`] || 0))}
137
+ onSave={(newVal) => {
138
+ if (hasMultiple) {
139
+ matches.forEach(m => updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal));
140
+ } else {
141
+ updatePlayerStat(livePlayer.ID, gw, "xMins", newVal);
142
+ }
143
+ }}
144
+ />
145
+ </div>
146
+ </td>
147
+ <td className="p-3 text-right font-mono font-bold text-cyan-400 drop-shadow-md">
148
+ {Number(livePlayer[`${gw}_Pts`] || 0).toFixed(2)}
149
+ </td>
150
+ </tr>
151
+
152
+ {hasMultiple && matches.map(m => {
153
+ const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id;
154
+ const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`;
155
+ const globalMatchMins = globalXmins?.[livePlayer.ID]?.[m.id];
156
+ const sessionVal = sessionEdits?.[livePlayer.ID]?.[`${m.id}_xMins`];
157
+
158
+ const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins));
159
+ const scaledEV = (currentMins > 0 && m.xMins > 0) ? (m.Pts / m.xMins) * currentMins : 0;
160
+
161
+ return (
162
+ <tr key={m.id} className="bg-slate-900/40 border-t border-slate-800/30">
163
+ <td className="p-2 text-right text-slate-600 font-black">↳</td>
164
+ <td className="p-2 text-center text-[10px] font-bold text-indigo-300">
165
+ {fixLabel} <span className="opacity-60">({Math.round(m.prob * 100)}%)</span>
166
+ </td>
167
+ <td className="p-2 flex justify-center">
168
+ <SafeMinsInput
169
+ isChild={true}
170
+ initialValue={currentMins}
171
+ onSave={(newVal) => updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal)}
172
+ />
173
+ </td>
174
+ <td className="p-2 text-right text-[11px] font-mono font-bold text-indigo-400/80">
175
+ {(scaledEV * m.prob).toFixed(2)}
176
+ </td>
177
+ </tr>
178
+ );
179
+ })}
180
+ </React.Fragment>
181
+ );
182
+ })}
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ <div className="flex gap-4 mt-2">
187
+ <button onClick={() => handleTransferOut(livePlayer)} className="flex-1 bg-red-950/40 border border-red-900/50 text-red-500 py-3 rounded-xl font-bold text-sm hover:bg-red-900/60 transition-colors">Transfer Out</button>
188
+ <button onClick={() => setSelectedPlayer(null)} className="flex-1 bg-luigi-500 hover:bg-luigi-400 text-slate-950 py-3 rounded-xl font-bold text-sm transition-colors shadow-lg">Apply Edits</button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ </div>
193
+ );
194
+ };
195
+
196
+ export const PlayerSearchModal = ({
197
+ selectedPlayer,
198
+ setSelectedPlayer,
199
+ searchQuery,
200
+ setSearchQuery,
201
+ sortConfig,
202
+ setSortConfig,
203
+ globalPlayers,
204
+ ownedPlayerIds,
205
+ activeGW,
206
+ itb,
207
+ handleAddPlayer,
208
+ }) => {
209
+ return (
210
+ <div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
211
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden flex flex-col animate-in zoom-in-95 duration-200">
212
+ <div className="p-4 border-b border-slate-800 flex items-center gap-3">
213
+ <Search className="text-slate-500" size={20} />
214
+ <input
215
+ type="text"
216
+ placeholder="Search Database..."
217
+ value={searchQuery}
218
+ onChange={(e) => setSearchQuery(e.target.value)}
219
+ className="flex-1 bg-transparent border-none outline-none text-slate-200 font-bold"
220
+ autoFocus
221
+ />
222
+ <button onClick={() => setSelectedPlayer(null)} className="text-slate-500 hover:text-white font-bold text-sm">Cancel</button>
223
+ </div>
224
+
225
+ <div className="flex gap-2 p-2 px-4 border-b border-slate-800 bg-slate-900/50 items-center">
226
+ <span className="text-[10px] font-black text-slate-500 tracking-widest mr-2">SORT BY:</span>
227
+ <button
228
+ onClick={() => setSortConfig({ key: "ev", direction: sortConfig.key === "ev" && sortConfig.direction === "desc" ? "asc" : "desc" })}
229
+ className={`px-3 py-1 rounded text-xs font-bold transition-colors ${sortConfig.key === "ev" ? "bg-emerald-900/50 text-emerald-400" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`}
230
+ >
231
+ Proj. EV {sortConfig.key === "ev" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""}
232
+ </button>
233
+ <button
234
+ onClick={() => setSortConfig({ key: "price", direction: sortConfig.key === "price" && sortConfig.direction === "desc" ? "asc" : "desc" })}
235
+ className={`px-3 py-1 rounded text-xs font-bold transition-colors ${sortConfig.key === "price" ? "bg-emerald-900/50 text-emerald-400" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`}
236
+ >
237
+ Price {sortConfig.key === "price" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""}
238
+ </button>
239
+ </div>
240
+
241
+ <div className="max-h-[400px] overflow-y-auto p-2">
242
+ {globalPlayers
243
+ // THE FIX: Removed the restrictive 'replacedPlayer' ban and added defensive FPL ID type-checking
244
+ .filter((p) => !ownedPlayerIds.has(p.ID) && !ownedPlayerIds.has(String(p.ID)) && !ownedPlayerIds.has(Number(p.ID)) && String(p.ID) !== String(selectedPlayer.replacedPlayer?.ID) && p.Pos === selectedPlayer.Pos && p.Name.toLowerCase().includes(searchQuery.toLowerCase()))
245
+ .sort((a, b) => {
246
+ let valA = sortConfig.key === "ev" ? Number(a[`${activeGW}_Pts`] || 0) : getPlayerPrice(a);
247
+ let valB = sortConfig.key === "ev" ? Number(b[`${activeGW}_Pts`] || 0) : getPlayerPrice(b);
248
+ if (valA < valB) return sortConfig.direction === "desc" ? 1 : -1;
249
+ if (valA > valB) return sortConfig.direction === "desc" ? -1 : 1;
250
+ return 0;
251
+ })
252
+ .slice(0, 50)
253
+ .map((p) => {
254
+ // THE FIX: Your true FPL purchasing power includes the money freed up by selling the outgoing player
255
+ const sellingPrice = getPlayerPrice(selectedPlayer) || 0;
256
+ const maxBudget = itb + sellingPrice;
257
+ const cost = getPlayerPrice(p);
258
+ const isAffordable = cost <= maxBudget;
259
+
260
+ return (
261
+ <button
262
+ key={p.ID}
263
+ disabled={!isAffordable}
264
+ onClick={() => handleAddPlayer(p)}
265
+ className={`w-full flex items-center justify-between p-3 border-b border-slate-800/30 transition-colors group ${isAffordable ? "hover:bg-slate-900 cursor-pointer" : "opacity-40 cursor-not-allowed"}`}
266
+ >
267
+ <div className="flex flex-col items-start text-left">
268
+ <span className="font-bold text-slate-200 text-sm">{p.Name}</span>
269
+ <span className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">{p.Team} • {p.Pos}</span>
270
+ </div>
271
+ <div className="flex items-center gap-4 text-right">
272
+ <div className="flex flex-col items-end">
273
+ <span className="text-xs font-mono text-emerald-400 font-bold">EV: {Number(p[`${activeGW}_Pts`] || 0).toFixed(2)}</span>
274
+ <span className="text-[10px] font-mono text-slate-400">{p[`${activeGW}_xMins`] || 0} xMins</span>
275
+ </div>
276
+ <span className={`text-sm font-mono font-bold ${isAffordable ? "text-slate-300" : "text-red-400"}`}>£{cost.toFixed(1)}m</span>
277
+ <Plus className={`transition-colors ${isAffordable ? "text-slate-600 group-hover:text-luigi-400" : "text-slate-800"}`} size={18} />
278
+ </div>
279
+ </button>
280
+ );
281
+ })}
282
+ </div>
283
+ </div>
284
+ </div>
285
+ );
286
+ };
frontend/src/components/ProjectionsTable.jsx ADDED
@@ -0,0 +1,664 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo, useRef, useContext } from 'react';
2
+ import { Search, ChevronLeft, ChevronRight, Shield, Download, RotateCcw, Loader2 } from 'lucide-react';
3
+ import { getShortName } from '../utils/teams';
4
+ import { PlayerContext } from '../PlayerContext';
5
+
6
+ // --- BASELINE INPUT WITH LIVE AUTO-SAVE ---
7
+ // --- BASELINE INPUT WITH LIVE AUTO-SAVE & SNAP-PROOF MEMORY ---
8
+ const BaselineInput = ({ player, handleUpdate }) => {
9
+ const [val, setVal] = useState(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
10
+
11
+ useEffect(() => {
12
+ setVal(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
13
+ }, [player.baseline_xMins]);
14
+
15
+ return (
16
+ <input
17
+ type="number"
18
+ value={val}
19
+ onChange={(e) => setVal(e.target.value)}
20
+ onBlur={() => {
21
+ let num = val === '' ? 0 : parseInt(val, 10);
22
+ num = Math.max(0, Math.min(90, num)); // CAP FIX: Locks between 0 and 90
23
+ setVal(num);
24
+ handleUpdate(player.ID, 'baseline', null, num);
25
+ }}
26
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
27
+ className="w-12 bg-transparent text-center font-mono text-sm font-bold text-emerald-400 focus:outline-none focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 rounded py-1 hover:bg-slate-800/50 transition-colors cursor-text [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
28
+ />
29
+ );
30
+ };
31
+
32
+ // --- DGW CHILD INPUT WITH LIVE AUTO-SAVE ---
33
+ const SafeChildInput = ({ initialValue, onSave }) => {
34
+ const [val, setVal] = useState(initialValue);
35
+ useEffect(() => setVal(initialValue), [initialValue]);
36
+ return (
37
+ <input
38
+ type="number"
39
+ value={val}
40
+ onChange={(e) => setVal(e.target.value)}
41
+ onBlur={() => {
42
+ let num = val === '' ? 0 : parseFloat(val);
43
+ num = Math.max(0, Math.min(90, num)); // CAP FIX
44
+ setVal(num);
45
+ onSave(num);
46
+ }}
47
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
48
+ className="w-12 bg-slate-950 text-center font-mono text-xs font-bold text-indigo-400 rounded py-1 outline-none focus:ring-1 ring-indigo-500 border border-slate-800 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
49
+ />
50
+ );
51
+ };
52
+
53
+ // --- GW INPUT WITH LIVE AUTO-SAVE & DGW POPOVER ---
54
+ // --- GW INPUT WITH SAFE FRONTEND SAVING ---
55
+ // --- GW INPUT WITH SAFE FRONTEND SAVING ---
56
+ const GwMinsInput = ({ player, gw, handleUpdate }) => {
57
+ const {effectiveFixtures, sessionEdits, globalXmins } = useContext(PlayerContext);
58
+ const [showPopover, setShowPopover] = useState(false);
59
+ const [isFocused, setIsFocused] = useState(false);
60
+ const popoverRef = useRef(null);
61
+
62
+ const [val, setVal] = useState(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
63
+
64
+ useEffect(() => {
65
+ setVal(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
66
+ }, [player[`${gw}_xMins`]]);
67
+
68
+ useEffect(() => {
69
+ const handleClickOutside = (event) => {
70
+ if (popoverRef.current && !popoverRef.current.contains(event.target)) setShowPopover(false);
71
+ };
72
+ if (showPopover) document.addEventListener('mousedown', handleClickOutside);
73
+ return () => document.removeEventListener('mousedown', handleClickOutside);
74
+ }, [showPopover]);
75
+
76
+ const matches = [];
77
+ if (player.match_projections) {
78
+ Object.entries(player.match_projections).forEach(([mId, mData]) => {
79
+ const override = effectiveFixtures?.[mId];
80
+ // THE FIX: Force Number() conversion so strings like "1" don't break the math!
81
+ if (override && override[gw] > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) });
82
+ else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 });
83
+ });
84
+ }
85
+
86
+ const hasMultiple = matches.length > 1 || (matches.length === 1 && Math.abs(matches[0].prob - 1.0) > 0.001);
87
+ const isBlank = matches.length === 0;
88
+
89
+ const TEAM_SHORTS = {
90
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
91
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
92
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
93
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
94
+ };
95
+
96
+ if (isBlank) return <span className="text-[10px] font-bold text-slate-600">-</span>;
97
+ const hoverFixtureText = matches.map(m => `${TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id} ${m.is_home ? '(H)' : '(A)'}`).join(" & ");
98
+
99
+ const handleParentSave = (newVal) => {
100
+ let numVal = newVal === '' ? 0 : parseFloat(newVal);
101
+ numVal = Math.max(0, Math.min(90, numVal)); // CAP FIX
102
+ const currentAvg = Math.round(player[`${gw}_xMins`] || 0);
103
+ if (numVal === currentAvg) {
104
+ setVal(numVal);
105
+ return;
106
+ }
107
+ setVal(numVal); // Snaps the UI instantly
108
+
109
+ if (hasMultiple) {
110
+ const edits = {};
111
+ matches.forEach(m => { edits[m.id] = numVal; });
112
+ handleUpdate(player.ID, 'batch', edits, null);
113
+ } else {
114
+ handleUpdate(player.ID, 'single', gw, numVal);
115
+ }
116
+ };
117
+
118
+ return (
119
+ <div className="relative flex justify-center w-full pl-2" ref={popoverRef} title={hoverFixtureText}>
120
+ {isFocused && !hasMultiple && !isBlank && (
121
+ <div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-slate-800 text-indigo-300 text-[10px] font-bold px-2 py-0.5 rounded shadow-xl border border-indigo-500/30 whitespace-nowrap z-[100] pointer-events-none animate-in fade-in zoom-in-95">
122
+ {hoverFixtureText}
123
+ </div>
124
+ )}
125
+ <input
126
+ type="number"
127
+ title={hoverFixtureText}
128
+ value={val}
129
+ onClick={() => hasMultiple && setShowPopover(true)}
130
+ onFocus={() => !hasMultiple && setIsFocused(true)}
131
+ onChange={(e) => !hasMultiple && setVal(e.target.value)}
132
+ onBlur={(e) => {
133
+ if (!hasMultiple) {
134
+ setIsFocused(false); // Hides tooltip when you click away
135
+ handleParentSave(e.target.value);
136
+ }
137
+ }}
138
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
139
+ className={`w-12 bg-transparent text-center font-mono text-sm font-bold rounded py-1 outline-none transition-colors ${hasMultiple ? 'text-indigo-300 hover:bg-slate-800/50 cursor-pointer focus:ring-1 ring-indigo-500' : 'text-emerald-400 focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 hover:bg-slate-800/50'} [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`}
140
+ />
141
+
142
+ {showPopover && hasMultiple && (
143
+ <div className="absolute top-0 right-full mr-2 w-48 bg-slate-900 border border-indigo-500/50 rounded-lg shadow-2xl z-[200] flex flex-col overflow-hidden animate-in fade-in zoom-in-95">
144
+ <div className="bg-indigo-950/50 text-[9px] font-bold text-indigo-300 uppercase tracking-widest p-2 border-b border-indigo-500/30 flex justify-between items-center">
145
+ <span>Edit Match Splits</span>
146
+ <button onClick={(e) => { e.stopPropagation(); setShowPopover(false); }} className="text-indigo-400 hover:text-white text-xs">✕</button>
147
+ </div>
148
+ <div className="p-2 flex flex-col gap-2">
149
+ {matches.map(m => {
150
+ const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id || "OPP";
151
+ const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`;
152
+ const globalMatchMins = globalXmins?.[player.ID]?.[m.id];
153
+ const sessionVal = sessionEdits?.[player.ID]?.[`${m.id}_xMins`];
154
+ const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins));
155
+
156
+ return (
157
+ <div key={m.id} className="flex items-center justify-between gap-2">
158
+ <span className="text-[10px] font-bold text-slate-300 truncate flex-1">{fixLabel} <span className="opacity-50">({Math.round(m.prob*100)}%)</span></span>
159
+ <SafeChildInput
160
+ initialValue={currentMins}
161
+ onSave={(newVal) => handleUpdate(player.ID, 'single', m.id, newVal)}
162
+ />
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ </div>
168
+ )}
169
+ </div>
170
+ );
171
+ };
172
+ export default function ProjectionsTable() {
173
+ const {
174
+ globalPlayers: players, setGlobalPlayers, isLoadingDB,
175
+ projSearchTerm: searchTerm, setProjSearchTerm: setSearchTerm,
176
+ sessionEdits, setSessionEdits, manualOverrides, effectiveFixtures,setOriginalPlayers,globalXmins
177
+ } = useContext(PlayerContext);
178
+
179
+ const [sortConfig, setSortConfig] = useState({ key: 'Total Points', direction: 'desc' });
180
+ const [currentPage, setCurrentPage] = useState(1);
181
+ const itemsPerPage = 50;
182
+
183
+ const tableContainerRef = useRef(null);
184
+ useEffect(() => {
185
+ if (tableContainerRef.current) {
186
+ tableContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
187
+ }
188
+ }, [currentPage]);
189
+
190
+ const [isAdmin, setIsAdmin] = useState(false);
191
+ const [adminPassword, setAdminPassword] = useState('');
192
+ const [showAdminLogin, setShowAdminLogin] = useState(false);
193
+ const [clickCount, setClickCount] = useState(0);
194
+ const clickTimeoutRef = useRef(null);
195
+
196
+ const handleSecretClick = () => {
197
+ setClickCount((prev) => {
198
+ const newCount = prev + 1;
199
+ if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; }
200
+ return newCount;
201
+ });
202
+ if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current);
203
+ clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000);
204
+ };
205
+
206
+ const gameweeks = useMemo(() => {
207
+ if (!players || players.length === 0) return [];
208
+ const gwSet = new Set();
209
+ Object.keys(players[0]).forEach(k => {
210
+ if (/^\d+_Pts$/.test(k)) {
211
+ const num = parseInt(k.split('_')[0], 10);
212
+ if (num >= 1 && num <= 38) gwSet.add(num);
213
+ }
214
+ });
215
+ return Array.from(gwSet).sort((a, b) => a - b);
216
+ }, [players]);
217
+
218
+ const getDynamicTotal = (p) => gameweeks.reduce((sum, gw) => sum + (Number(p[`${gw}_Pts`]) || 0), 0);
219
+ const getDynamicAvg = (p) => gameweeks.length > 0 ? getDynamicTotal(p) / gameweeks.length : 0;
220
+
221
+ const handleUpdate = async (playerId, type, gw, valueStr) => {
222
+ const value = type === 'baseline' ? parseInt(valueStr, 10) || 0 : parseFloat(valueStr) || 0;
223
+
224
+ // 1. Grab the active baseline from memory so Python doesn't forget it!
225
+ const activeBaseline = sessionEdits[playerId]?.baseline_xMins;
226
+
227
+ // 2. Prevent Python Crash: Separate Match IDs (13_vs_1) from real Gameweeks (34)
228
+ const realGwEdits = {};
229
+ if (type === 'batch') {
230
+ Object.keys(gw).forEach(k => { realGwEdits[k] = gw[k]; });
231
+ } else if (type === 'single') {
232
+ realGwEdits[gw] = value;
233
+ }
234
+
235
+ // 3. Update local React memory instantly (handles DGW splits perfectly)
236
+ setSessionEdits(prev => {
237
+ const next = { ...prev };
238
+ if (!next[playerId]) next[playerId] = {};
239
+
240
+ if (type === 'baseline') {
241
+ next[playerId]['baseline_xMins'] = value;
242
+ } else if (type === 'batch') {
243
+ Object.keys(gw).forEach(k => { next[playerId][`${k}_xMins`] = gw[k]; });
244
+ } else {
245
+ next[playerId][`${gw}_xMins`] = value;
246
+ }
247
+
248
+ const token = localStorage.getItem('fpl_token');
249
+ if (token) {
250
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
251
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
252
+ body: JSON.stringify({ saved_edits: { ...next, _solver_overrides: manualOverrides } })
253
+ });
254
+ }
255
+ return next;
256
+ });
257
+
258
+ // 4. If this is a DGW match split ('13_vs_1'), STOP HERE. Don't crash Python!
259
+
260
+ try {
261
+ const payload = { player_id: playerId, is_admin: isAdmin, admin_password: adminPassword, gw_edits: realGwEdits };
262
+
263
+ // 5. Prevent Reset Bug: ALWAYS send the baseline to Python!
264
+ if (type === 'baseline') {
265
+ payload.baseline_edit = value;
266
+ } else if (activeBaseline !== undefined) {
267
+ payload.baseline_edit = activeBaseline;
268
+ }
269
+
270
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/player/update', {
271
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
272
+ });
273
+ if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend recalculation failed'); }
274
+
275
+ const updatedRow = await res.json();
276
+
277
+ // 6. Merge Python's exact decayed math into the table
278
+ if (setGlobalPlayers) {
279
+ setGlobalPlayers(prev => prev.map(p => {
280
+ const newBaseline = type === 'baseline' ? (valueStr === '' ? null : value) : (activeBaseline !== undefined ? activeBaseline : p.baseline_xMins);
281
+ if (p.ID === playerId) return { ...p, ...updatedRow, baseline_xMins: newBaseline };
282
+ return p;
283
+ }));
284
+ }
285
+
286
+ // 7. Lock Python's curve into memory (from your old working file)
287
+ if (type === 'baseline') {
288
+ setSessionEdits(prev => {
289
+ const next = { ...prev };
290
+ gameweeks.forEach(g => {
291
+ next[playerId][`${g}_xMins`] = updatedRow[`${g}_xMins`];
292
+ next[playerId][`${g}_Pts`] = updatedRow[`${g}_Pts`];
293
+ });
294
+ return next;
295
+ });
296
+ }
297
+
298
+ } catch (err) { console.error("Recalculation error:", err); }
299
+ };
300
+
301
+ const resetPlayer = async (playerId) => {
302
+ try {
303
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
304
+ const freshData = await res.json();
305
+ const cleanPlayer = freshData.find(p => p.ID === playerId);
306
+ if (cleanPlayer && setGlobalPlayers) {
307
+
308
+ // THE FLICKER FIX: Apply the UI math interceptor instantly before merging the reset player!
309
+ if (cleanPlayer.match_projections) {
310
+ gameweeks.forEach(g => {
311
+ cleanPlayer[`${g}_Pts`] = 0;
312
+ cleanPlayer[`${g}_xMins`] = 0;
313
+ cleanPlayer[`${g}_probSum`] = 0;
314
+ });
315
+ Object.entries(cleanPlayer.match_projections).forEach(([mId, mData]) => {
316
+ const pts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
317
+ const mins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
318
+ const override = effectiveFixtures?.[mId];
319
+ if (override) {
320
+ Object.entries(override).forEach(([gwStr, prob]) => {
321
+ if (prob > 0) {
322
+ cleanPlayer[`${gwStr}_Pts`] = (cleanPlayer[`${gwStr}_Pts`] || 0) + (pts * prob);
323
+ cleanPlayer[`${gwStr}_xMins`] = (cleanPlayer[`${gwStr}_xMins`] || 0) + (mins * prob);
324
+ cleanPlayer[`${gwStr}_probSum`] = (cleanPlayer[`${gwStr}_probSum`] || 0) + prob;
325
+ }
326
+ });
327
+ } else {
328
+ const defGw = mData.default_gw;
329
+ if (defGw) {
330
+ cleanPlayer[`${defGw}_Pts`] = (cleanPlayer[`${defGw}_Pts`] || 0) + pts;
331
+ cleanPlayer[`${defGw}_xMins`] = (cleanPlayer[`${defGw}_xMins`] || 0) + mins;
332
+ cleanPlayer[`${defGw}_probSum`] = (cleanPlayer[`${defGw}_probSum`] || 0) + 1.0;
333
+ }
334
+ }
335
+ });
336
+ gameweeks.forEach(g => {
337
+ if (cleanPlayer[`${g}_probSum`] > 0) {
338
+ cleanPlayer[`${g}_xMins`] = cleanPlayer[`${g}_xMins`] / cleanPlayer[`${g}_probSum`];
339
+ }
340
+ });
341
+ }
342
+
343
+ setGlobalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
344
+ if (setOriginalPlayers) {
345
+ setOriginalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
346
+ }
347
+ setSessionEdits(prev => {
348
+ const newEdits = { ...prev };
349
+ delete newEdits[playerId];
350
+ const token = localStorage.getItem('fpl_token');
351
+ if (token) {
352
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
353
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
354
+ body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverrides } })
355
+ });
356
+ }
357
+ return newEdits;
358
+ });
359
+ }
360
+ } catch (e) { console.error("Failed to reset player", e); }
361
+ };
362
+
363
+ const resetAll = async () => {
364
+ try {
365
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
366
+ const freshData = await res.json();
367
+ if (setGlobalPlayers) setGlobalPlayers(freshData);
368
+ setSessionEdits(prev => {
369
+ const token = localStorage.getItem('fpl_token');
370
+ if (token) {
371
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
372
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
373
+ body: JSON.stringify({ saved_edits: { _solver_overrides: manualOverrides } })
374
+ });
375
+ }
376
+ return {};
377
+ });
378
+ } catch (e) { console.error("Failed to reset all", e); }
379
+ };
380
+
381
+ const downloadCSV = () => {
382
+ if (!players || players.length === 0) return;
383
+ const headers = ["Pos", "ID", "Name", "BV", "SV", "Team"];
384
+ gameweeks.forEach(gw => {
385
+ headers.push(`${gw}_xMins`, `${gw}_Pts`);
386
+ });
387
+ headers.push("Total Points", "Average Points");
388
+
389
+ let csvContent = "data:text/csv;charset=utf-8,";
390
+ csvContent += headers.join(",") + "\n";
391
+
392
+ const escapeCsv = (str) => {
393
+ if (str == null) return "";
394
+ const s = String(str);
395
+ return s.includes(",") ? `"${s}"` : s;
396
+ };
397
+
398
+ sortedAndFilteredData.forEach(p => {
399
+ const row = [
400
+ p.Pos,
401
+ p.ID,
402
+ escapeCsv(p.Name),
403
+ p.BV,
404
+ p.SV !== undefined ? p.SV : p.BV,
405
+ escapeCsv(p.Team)
406
+ ];
407
+ gameweeks.forEach(gw => {
408
+ const mins = Number(p[`${gw}_xMins`]) || 0;
409
+ const pts = Number(p[`${gw}_Pts`]) || 0;
410
+ row.push(Math.round(mins));
411
+ row.push(pts.toFixed(2));
412
+ });
413
+ row.push(getDynamicTotal(p).toFixed(2));
414
+ row.push(getDynamicAvg(p).toFixed(2));
415
+ csvContent += row.join(",") + "\n";
416
+ });
417
+
418
+ const encodedUri = encodeURI(csvContent);
419
+ const link = document.createElement("a");
420
+ link.setAttribute("href", encodedUri);
421
+ link.setAttribute("download", `luigis_mansion.csv`);
422
+
423
+ document.body.appendChild(link);
424
+ link.click();
425
+ document.body.removeChild(link);
426
+ };
427
+
428
+ const handleSort = (key) => {
429
+ let direction = 'desc';
430
+ if (sortConfig.key === key && sortConfig.direction === 'desc') direction = 'asc';
431
+ setSortConfig({ key, direction });
432
+ };
433
+
434
+ const sortedAndFilteredData = useMemo(() => {
435
+ if (!players) return [];
436
+
437
+ // Add the special character normalizer
438
+ const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : "";
439
+ const cleanSearch = cleanString(searchTerm);
440
+
441
+ let filtered = searchTerm ? players.filter(p => cleanString(p.Name).includes(cleanSearch)) : [...players];
442
+
443
+ return filtered.sort((a, b) => {
444
+ let valA = sortConfig.key === 'Total Points' ? getDynamicTotal(a) : (sortConfig.key === 'Average Points' ? getDynamicAvg(a) : a[sortConfig.key]);
445
+ let valB = sortConfig.key === 'Total Points' ? getDynamicTotal(b) : (sortConfig.key === 'Average Points' ? getDynamicAvg(b) : b[sortConfig.key]);
446
+ if (sortConfig.key === 'Team') { valA = getShortName(valA); valB = getShortName(valB); }
447
+ if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
448
+ if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
449
+ return 0;
450
+ });
451
+ }, [players, sortConfig, searchTerm, gameweeks]);
452
+
453
+ useEffect(() => setCurrentPage(1), [searchTerm, sortConfig]);
454
+
455
+ const totalPages = Math.ceil(sortedAndFilteredData.length / itemsPerPage);
456
+ const paginatedData = sortedAndFilteredData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
457
+
458
+ const getMinsColor = (mins) => `rgba(52, 211, 153, ${Math.min(mins / 90, 1) * 0.4})`;
459
+ const getPtsColor = (pts) => pts <= 0 ? 'transparent' : `rgba(16, 185, 129, ${Math.min(pts / 10, 1) * 0.6})`;
460
+
461
+ if (isLoadingDB) return <div className="flex items-center justify-center h-64"><Loader2 size={32} className="animate-spin text-emerald-500" /></div>;
462
+
463
+ const displayedPlayers = useMemo(() => {
464
+ return paginatedData.map(p => {
465
+ if (!p.match_projections) return p;
466
+ const cloned = { ...p };
467
+
468
+ gameweeks.forEach(g => {
469
+ cloned[`${g}_Pts`] = 0;
470
+ cloned[`${g}_xMins`] = 0;
471
+ cloned[`${g}_probSum`] = 0;
472
+ });
473
+
474
+ const manualBaseline = sessionEdits[p.ID]?.baseline_xMins;
475
+
476
+ Object.entries(p.match_projections).forEach(([mId, mData]) => {
477
+ const override = effectiveFixtures?.[mId];
478
+
479
+ let manualMins = sessionEdits[p.ID]?.[`${mId}_xMins`];
480
+ const globalMatchMins = globalXmins?.[p.ID]?.[mId];
481
+ if (manualMins === undefined) {
482
+ if (globalMatchMins !== undefined) {
483
+ manualMins = globalMatchMins;
484
+ } else {
485
+ let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw;
486
+ if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw];
487
+ }
488
+ }
489
+
490
+ // Safely get the unedited minutes from the backend
491
+ const origMins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
492
+ let activeMins = origMins;
493
+
494
+ // THE DECAY FIX: Use a ratio to preserve the backend curve instead of flattening it
495
+ if (manualMins !== undefined) {
496
+ activeMins = Number(manualMins);
497
+ } else if (manualBaseline !== undefined) {
498
+ const origBase = p.baseline_xMins || 90;
499
+ const ratio = origBase > 0 ? (Number(manualBaseline) / origBase) : 1.0;
500
+ activeMins = Math.min((origMins * ratio), 90);
501
+ }
502
+
503
+ const scaling = (activeMins > 0 && origMins > 0) ? (activeMins / origMins) : (activeMins === 0 ? 0 : 1);
504
+ const basePts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
505
+ const aPts = basePts * scaling;
506
+
507
+ if (override) {
508
+ Object.entries(override).forEach(([gwStr, prob]) => {
509
+ if (prob > 0) {
510
+ cloned[`${gwStr}_Pts`] = (cloned[`${gwStr}_Pts`] || 0) + (aPts * prob);
511
+ cloned[`${gwStr}_xMins`] = (cloned[`${gwStr}_xMins`] || 0) + (activeMins * prob);
512
+ cloned[`${gwStr}_probSum`] = (cloned[`${gwStr}_probSum`] || 0) + prob;
513
+ }
514
+ });
515
+ } else {
516
+ const defGw = mData.default_gw;
517
+ if (defGw) {
518
+ cloned[`${defGw}_Pts`] = (cloned[`${defGw}_Pts`] || 0) + aPts;
519
+ cloned[`${defGw}_xMins`] = (cloned[`${defGw}_xMins`] || 0) + activeMins;
520
+ cloned[`${defGw}_probSum`] = (cloned[`${defGw}_probSum`] || 0) + 1.0;
521
+ }
522
+ }
523
+ });
524
+
525
+ gameweeks.forEach(g => {
526
+ if (cloned[`${g}_probSum`] > 0) {
527
+ cloned[`${g}_xMins`] = cloned[`${g}_xMins`] / cloned[`${g}_probSum`];
528
+ }
529
+ });
530
+
531
+ return cloned;
532
+ });
533
+ }, [paginatedData, sessionEdits, effectiveFixtures, gameweeks]);
534
+
535
+ return (
536
+ <div className="space-y-4 w-full">
537
+ <div className="flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-900/40 p-4 rounded-xl border border-slate-800 backdrop-blur-sm shadow-sm">
538
+ <div className="flex gap-4 items-center">
539
+ <div className="relative w-72 flex items-center">
540
+ <div className="absolute left-0 w-10 h-full flex items-center justify-center cursor-pointer z-10" onClick={handleSecretClick}>
541
+ <Search className="text-slate-500 pointer-events-none" size={18} />
542
+ </div>
543
+ <input type="text" placeholder="Search players..." value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="w-full bg-slate-950/80 border border-slate-700 rounded-lg py-2 pl-10 pr-10 text-sm text-slate-200 focus:outline-none focus:border-luigi-400" />
544
+ {isAdmin && <Shield size={14} className="absolute right-3 text-luigi-500" title="Admin Mode Active" />}
545
+ </div>
546
+ {showAdminLogin && !isAdmin && (
547
+ <div className="flex gap-2 animate-in fade-in slide-in-from-left-4 duration-300">
548
+ <input type="password" placeholder="Admin Pass" value={adminPassword} onChange={(e) => setAdminPassword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && setIsAdmin(true)} className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-sm w-32 focus:outline-none focus:border-luigi-400 text-slate-200" />
549
+ <button onClick={() => setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-sm text-white transition-colors">Login</button>
550
+ </div>
551
+ )}
552
+ </div>
553
+ <div className="flex gap-3">
554
+ {Object.keys(sessionEdits).length > 0 && (
555
+ <button onClick={resetAll} className="flex items-center gap-2 px-3 py-2 text-sm bg-red-900/30 text-red-400 border border-red-900/50 rounded-lg hover:bg-red-900/50 transition-colors"><RotateCcw size={16} /> Reset to Default</button>
556
+ )}
557
+ <button onClick={downloadCSV} className="flex items-center gap-2 px-4 py-2 text-sm bg-luigi-500 text-slate-950 font-bold rounded-lg hover:bg-luigi-400 transition-colors shadow-lg shadow-luigi-500/20"><Download size={16} /> Export CSV</button>
558
+ </div>
559
+ </div>
560
+
561
+ <div ref={tableContainerRef} className="rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-sm shadow-xl max-h-[70vh] overflow-y-auto overflow-x-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-700/50 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-slate-600/80">
562
+ <table className="w-full text-sm text-left text-slate-300 relative">
563
+ <thead className="text-[11px] text-slate-400 uppercase bg-slate-950 border-b border-slate-800 sticky top-0 z-10 shadow-sm">
564
+ <tr>
565
+ <th onClick={() => handleSort('Pos')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Pos</th>
566
+ <th onClick={() => handleSort('Name')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Name</th>
567
+ <th onClick={() => handleSort('Team')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Team</th>
568
+ <th onClick={() => handleSort('BV')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Cost</th>
569
+ <th onClick={() => handleSort('baseline_xMins')} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-900/50 cursor-pointer">
570
+ <div className="flex flex-col items-center gap-1"><span className="text-emerald-400 font-bold tracking-wider">Baseline</span><div className="flex w-full text-[10px] text-slate-500 justify-center px-1">xMins</div></div>
571
+ </th>
572
+ {gameweeks.map(gw => (
573
+ <th key={gw} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-950">
574
+ <div className="flex flex-col items-center gap-1">
575
+ <span className="text-luigi-400 font-bold tracking-wider">GW{gw}</span>
576
+ <div className="flex w-full text-[10px] text-slate-500 justify-around px-1 gap-2">
577
+ <span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_xMins`)}>xMins</span>
578
+ <span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_Pts`)}>xPts</span>
579
+ </div>
580
+ </div>
581
+ </th>
582
+ ))}
583
+ <th onClick={() => handleSort('Total Points')} className="px-3 py-4 text-right cursor-pointer hover:text-slate-200 text-luigi-400 border-l border-slate-800/50 bg-slate-950 whitespace-nowrap">Total</th>
584
+ <th className="px-3 py-4 text-center bg-slate-950 border-l border-slate-800/50 whitespace-nowrap">Reset</th>
585
+ </tr>
586
+ </thead>
587
+
588
+ <tbody className="divide-y divide-slate-800/50">
589
+ {displayedPlayers.map((player) => (
590
+ <tr key={player.ID} className={`transition-colors group ${sessionEdits[player.ID] ? 'bg-luigi-900/10' : 'hover:bg-slate-800/30'}`}>
591
+ <td className="px-3 py-2 font-medium text-slate-500 text-center whitespace-nowrap">{player.Pos}</td>
592
+ <td className="px-3 py-2 font-bold text-slate-100 truncate max-w-[160px]">{player.Name}</td>
593
+ <td className="px-3 py-2 text-slate-400 font-bold text-center">{getShortName(player.Team)}</td>
594
+ <td className="px-3 py-2 text-center whitespace-nowrap">{player.BV}</td>
595
+
596
+ <td className="p-0 border-l border-slate-800/30 bg-slate-900/30">
597
+ <div className="w-full h-full p-1.5 flex items-center justify-center">
598
+ <BaselineInput
599
+ player={player}
600
+ handleUpdate={handleUpdate}
601
+ />
602
+ </div>
603
+ </td>
604
+
605
+ {gameweeks.map(gw => (
606
+ <td key={gw} className="p-0 border-l border-slate-800/30">
607
+ <div className="flex h-full items-stretch">
608
+ <div className="relative w-1/2 p-2 border-r border-slate-800/20 flex items-center justify-center" style={{ backgroundColor: getMinsColor(player[`${gw}_xMins`]) }}>
609
+ <GwMinsInput
610
+ player={player}
611
+ gw={gw}
612
+ handleUpdate={handleUpdate}
613
+ />
614
+
615
+ {/* TAG FIX: Tiny, padded, and pointer-events-none so it never blocks clicks */}
616
+ {player[`${gw}_probSum`] > 1.01 && (
617
+ <span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-indigo-500/90 text-white rounded-bl font-black tracking-tighter pointer-events-none" title={`DGW`}>
618
+ DGW
619
+ </span>
620
+ )}
621
+ {player[`${gw}_probSum`] < 0.99 && player[`${gw}_probSum`] > 0.01 && (
622
+ <span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-orange-500/90 text-white rounded-bl font-black pointer-events-none" title="Odds of the fixture happening">
623
+ %
624
+ </span>
625
+ )}
626
+ </div>
627
+ <div className="w-1/2 p-2 text-center font-mono text-sm font-bold flex items-center justify-center" style={{ backgroundColor: getPtsColor(player[`${gw}_Pts`]) }}>
628
+ <span className="drop-shadow-md">{Number(player[`${gw}_Pts`]).toFixed(2)}</span>
629
+ </div>
630
+ </div>
631
+ </td>
632
+ ))}
633
+
634
+ <td className="px-3 py-2 text-right font-bold text-luigi-400 font-mono border-l border-slate-800/30 bg-slate-900/20 group-hover:bg-transparent">{getDynamicTotal(player).toFixed(2)}</td>
635
+ <td className="px-2 py-2 text-center border-l border-slate-800/30">
636
+ {sessionEdits[player.ID] && (
637
+ <button onClick={() => resetPlayer(player.ID)} className="p-1 text-slate-500 hover:text-red-400 transition-colors" title="Reset Player">
638
+ <RotateCcw size={16} />
639
+ </button>
640
+ )}
641
+ </td>
642
+ </tr>
643
+ ))}
644
+ </tbody>
645
+ </table>
646
+ </div>
647
+ {totalPages > 1 && (
648
+ <div className="flex items-center justify-between px-4 py-3 bg-slate-900/40 border border-slate-800 rounded-xl mt-2">
649
+ <span className="text-sm text-slate-400">
650
+ Showing <span className="font-bold text-slate-200">{(currentPage - 1) * itemsPerPage + 1}</span> to <span className="font-bold text-slate-200">{Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)}</span> of <span className="font-bold text-slate-200">{sortedAndFilteredData.length}</span> players
651
+ </span>
652
+ <div className="flex gap-2">
653
+ <button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors">
654
+ <ChevronLeft size={20} />
655
+ </button>
656
+ <button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors">
657
+ <ChevronRight size={20} />
658
+ </button>
659
+ </div>
660
+ </div>
661
+ )}
662
+ </div>
663
+ );
664
+ }
frontend/src/components/Solver.jsx ADDED
@@ -0,0 +1,1806 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useMemo, useContext, useRef } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { Search, Loader2, RotateCcw, Shield, Settings, Zap, Plus, Copy, Trash2 } from "lucide-react";
4
+ import { DndContext, DragOverlay, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
5
+ import { PlayerContext } from "../PlayerContext";
6
+ import { CHIP_CONFIG, getPlayerPrice, normalizeBenchGkFirst } from "../utils/fplLogic";
7
+ import { useFplSolverApi } from "../hooks/useFplSolverApi";
8
+ import { SolverOutputPanel } from "./SolverOutputPanel";
9
+ import { PitchView } from "./PitchView";
10
+ import { PlayerEditModal, PlayerSearchModal } from "./PlayerModals";
11
+ import { PlayerCardVisual } from "./PlayerCardVisual";
12
+ import { TabsPanel } from "./TabsPanel";
13
+ import { AdvancedSettingsModal, DEFAULT_SETTINGS } from "./AdvancedSettingsModal";
14
+ import { ActiveMovesPanel } from "./ActiveMovesPanel";
15
+ import { DraftsComparisonTable } from "./DraftsComparisonTable";
16
+
17
+ export default function Solver() {
18
+ const {
19
+ globalPlayers, updatePlayerStat, isLoadingDB, teamId, setTeamId, teamData, setTeamData, availableGWs, setAvailableGWs, horizon, setHorizon, activeGW, setActiveGW, captainId, setCaptainId, viceId, setViceId, initialSquadIds, setInitialSquadIds, isLoggedIn, userProfile, setUserProfile, manualOverrides, setManualOverrides, highlightTransferIds, setHighlightTransferIds, transfersByGw, setTransfersByGw, chipsByGw, setChipsByGw, baselineItb, setBaselineItb, baselineFt, setBaselineFt, availableFts, setAvailableFts, itb, setItb, HIT_COST, ftAtStartOfGw, quickSettings, setQuickSettings, advancedSettings, setAdvancedSettings, numSims, setNumSims, comprehensiveSettings, setComprehensiveSettings, appliedPlanSummary, setAppliedPlanSummary, solverApplySnapshot, setSolverApplySnapshot, solverTransferPairs, setSolverTransferPairs, solveElapsedSec, setSolveElapsedSec, drafts, setDrafts, activeDraftId, setActiveDraftId, fixtureOverrides, sessionEdits
20
+ } = useContext(PlayerContext);
21
+
22
+ // --- THE PRISTINE VAULT ---
23
+ const pristineSquadRef = useRef({});
24
+
25
+ const [pendingAutoReset, setPendingAutoReset] = useState(false);
26
+ const lastOverridesRef = useRef(fixtureOverrides);
27
+
28
+ // Watches for fixture changes and queues an auto-reset for when the math finishes
29
+ useEffect(() => {
30
+ if (lastOverridesRef.current !== fixtureOverrides) {
31
+ lastOverridesRef.current = fixtureOverrides;
32
+ setPendingAutoReset(true);
33
+ }
34
+ }, [fixtureOverrides]);
35
+
36
+ // --- STRICT VAULT-BASED PLAYER FACTORY ---
37
+ const hydratePlayer = (id, knownPristineData = null) => {
38
+ const globalMatch = globalPlayers.find((p) => String(p.ID) === String(id));
39
+ if (!globalMatch) return null;
40
+
41
+ // 1. Trust explicit overrides (like when clicking 'undo transfer')
42
+ if (knownPristineData && typeof knownPristineData === "object" && knownPristineData.purchase_price !== undefined) {
43
+ const hydrated = { ...globalMatch, ...knownPristineData };
44
+ hydrated.now_cost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price;
45
+ for (const key in globalMatch) { if (key.includes("_Pts")) hydrated[key] = globalMatch[key]; }
46
+ hydrated.Price = hydrated.selling_price !== undefined ? hydrated.selling_price : getPlayerPrice(hydrated);
47
+ return hydrated;
48
+ }
49
+
50
+ const marketCost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price;
51
+ const lockedBaselinePlayer = pristineSquadRef.current[id];
52
+
53
+ // 2. CHECK THE CHAIN: Was this player sold in any previous gameweek?
54
+ let isChainBroken = false;
55
+
56
+ if (lockedBaselinePlayer && availableGWs && availableGWs.length > 0) {
57
+ const pastGWs = availableGWs.filter(g => g < activeGW).sort((a, b) => a - b);
58
+
59
+ for (const gw of pastGWs) {
60
+ if (chipsByGw[gw] === "fh") continue; // FH sells do not break the permanent chain
61
+
62
+ // Check human moves
63
+ const mLock = manualOverrides[gw];
64
+ if (mLock?.manualTransfers && Object.values(mLock.manualTransfers).some(p => String(p?.ID) === String(id))) {
65
+ isChainBroken = true; break;
66
+ }
67
+
68
+ // Check solver moves
69
+ const sPairs = solverTransferPairs[gw];
70
+ if (sPairs && Object.values(sPairs).some(pair => String(pair.outPlayer?.ID) === String(id))) {
71
+ isChainBroken = true; break;
72
+ }
73
+ }
74
+ } else {
75
+ // If they aren't in the vault, they were bought after GW1. The chain is inherently broken.
76
+ isChainBroken = true;
77
+ }
78
+
79
+ // 3. APPLY THE LOGIC
80
+ let finalPurchasePrice, finalSellingPrice;
81
+
82
+ if (lockedBaselinePlayer && !isChainBroken) {
83
+ // Chain unbroken: They are a GW1 original. Use the locked vault prices.
84
+ finalPurchasePrice = lockedBaselinePlayer.purchase_price;
85
+ finalSellingPrice = lockedBaselinePlayer.selling_price !== undefined ? lockedBaselinePlayer.selling_price : getPlayerPrice(lockedBaselinePlayer);
86
+ } else {
87
+ // Chain broken: They were bought later, or sold and repurchased. Price resets to market cost.
88
+ finalPurchasePrice = marketCost;
89
+ finalSellingPrice = marketCost;
90
+ }
91
+
92
+ const hydrated = {
93
+ ...globalMatch,
94
+ ...(lockedBaselinePlayer && !isChainBroken ? lockedBaselinePlayer : {}),
95
+ purchase_price: finalPurchasePrice,
96
+ selling_price: finalSellingPrice,
97
+ Price: finalSellingPrice, // Lock the display value
98
+ now_cost: marketCost
99
+ };
100
+
101
+ // Overlay freshest points
102
+ for (const key in globalMatch) {
103
+ if (key.includes("_Pts")) hydrated[key] = globalMatch[key];
104
+ }
105
+
106
+ return hydrated;
107
+ };
108
+
109
+ const [isLoading, setIsLoading] = useState(false);
110
+ const [error, setError] = useState(null);
111
+ const [fixtures, setFixtures] = useState([]);
112
+ const [activeDragPlayer, setActiveDragPlayer] = useState(null);
113
+ const [selectedPlayer, setSelectedPlayer] = useState(null);
114
+ const [searchQuery, setSearchQuery] = useState("");
115
+ const [sortConfig, setSortConfig] = useState({ key: "ev", direction: "desc" });
116
+ const [showIdPrompt, setShowIdPrompt] = useState(false);
117
+ // --- DEFAULT ID ONBOARDING STATE ---
118
+ const [showInitialIdPrompt, setShowInitialIdPrompt] = useState(false);
119
+ const [initialIdInput, setInitialIdInput] = useState("");
120
+
121
+ // Trigger popup if logged in but no default ID is set
122
+ useEffect(() => {
123
+ if (isLoggedIn && userProfile && !userProfile.defaultTeamId) {
124
+ setShowInitialIdPrompt(true);
125
+ } else {
126
+ setShowInitialIdPrompt(false);
127
+ }
128
+ }, [isLoggedIn, userProfile]);
129
+
130
+ const handleSaveInitialId = () => {
131
+ const parsedId = parseInt(initialIdInput);
132
+ if (!parsedId) return;
133
+
134
+ const token = localStorage.getItem('fpl_token');
135
+ if (token) {
136
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
139
+ body: JSON.stringify({ default_team_id: parsedId })
140
+ });
141
+ setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId }));
142
+ setTeamId(String(parsedId)); // Auto-load the ID for them
143
+ setShowInitialIdPrompt(false);
144
+ }
145
+ };
146
+ const [pendingTeamId, setPendingTeamId] = useState(null);
147
+ const [lastLoadedId, setLastLoadedId] = useState(teamData.length > 0 ? teamId : null);
148
+
149
+ const [solverTab, setSolverTab] = useState("solver");
150
+ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
151
+ const [banSearch, setBanSearch] = useState("");
152
+ const [lockSearch, setLockSearch] = useState("");
153
+ const [chipSolveOptions, setChipSolveOptions] = useState({ wc: [], fh: [], bb: [], tc: [] });
154
+ const [showDraftMenu, setShowDraftMenu] = useState(false);
155
+
156
+ const [sensTimer, setSensTimer] = useState(0);
157
+ const [chipSolveTimer, setChipSolveTimer] = useState(0);
158
+
159
+ const abortControllerRef = useRef(null);
160
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
161
+
162
+ const {
163
+ isSolving, isChipSolving, isRunningSens, pendingSolutions, setPendingSolutions, chipSolveSolutions, setChipSolveSolutions, sensResults, setSensResults, sensViewGw, setSensViewGw, handleSolve: apiHandleSolve, handleChipSolve: apiHandleChipSolve, handleSensAnalysis: apiHandleSensAnalysis, loadSettingsFromCloud, saveSettingsToCloud
164
+ } = useFplSolverApi(abortControllerRef);
165
+
166
+ useEffect(() => {
167
+ let interval;
168
+ if (isRunningSens) { interval = setInterval(() => setSensTimer((t) => t + 1), 1000); } else { setSensTimer(0); }
169
+ return () => clearInterval(interval);
170
+ }, [isRunningSens]);
171
+
172
+ useEffect(() => {
173
+ let interval;
174
+ if (isChipSolving) { interval = setInterval(() => setChipSolveTimer((t) => t + 1), 1000); } else { setChipSolveTimer(0); }
175
+ return () => clearInterval(interval);
176
+ }, [isChipSolving]);
177
+
178
+ const maxAvailableHorizon = useMemo(() => (availableGWs.length ? Math.min(10, availableGWs.length) : 10), [availableGWs]);
179
+ const horizonGWs = useMemo(() => (availableGWs.length ? availableGWs.slice(0, horizon) : []), [availableGWs, horizon]);
180
+ const playerCardGWs = useMemo(() => {
181
+ if (!horizonGWs.length || !activeGW) return [];
182
+ const idx = horizonGWs.indexOf(activeGW);
183
+ return idx === -1 ? [] : horizonGWs.slice(idx).slice(0, 3);
184
+ }, [horizonGWs, activeGW]);
185
+ const solveGWs = useMemo(() => {
186
+ if (!horizonGWs.length || !activeGW) return horizonGWs;
187
+ const idx = horizonGWs.indexOf(activeGW);
188
+ return idx === -1 ? horizonGWs : horizonGWs.slice(idx);
189
+ }, [horizonGWs, activeGW]);
190
+
191
+ const solveGWLabel = useMemo(() => {
192
+ if (!solveGWs.length) return "";
193
+ return solveGWs.length === 1 ? `GW${solveGWs[0]}` : `GW${solveGWs[0]}–${solveGWs[solveGWs.length - 1]}`;
194
+ }, [solveGWs]);
195
+
196
+ const ownedPlayerIds = useMemo(() => new Set(teamData.filter((p) => !p.isBlank).map((p) => p.ID)), [teamData]);
197
+
198
+ const hitsThisGw = useMemo(() => {
199
+ const T = transfersByGw[activeGW]?.count || 0;
200
+ const chip = chipsByGw[activeGW];
201
+ if (chip === "wc" || chip === "fh") return 0;
202
+ const startFt = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
203
+ return Math.max(0, T - startFt);
204
+ }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw]);
205
+
206
+ // 1. Standard state (can default to your hardcoded defaults initially)
207
+ const [isCloudLoaded, setIsCloudLoaded] = useState(false);
208
+
209
+ // 1. Fetch from Cloud on Mount / Login
210
+ useEffect(() => {
211
+ if (teamId && !isCloudLoaded) {
212
+ loadSettingsFromCloud(teamId).then((cloudData) => {
213
+ if (cloudData) {
214
+ if (cloudData.quick) {
215
+ setQuickSettings(prev => ({ ...prev, ...cloudData.quick }));
216
+ }
217
+ // THE FIX: Use setComprehensiveSettings!
218
+ if (cloudData.advanced) {
219
+ setComprehensiveSettings(prev => ({ ...prev, ...cloudData.advanced }));
220
+ }
221
+ }
222
+ setIsCloudLoaded(true);
223
+ });
224
+ }
225
+ }, [teamId]);
226
+
227
+ // 2. Save to Cloud (DEBOUNCED)
228
+ useEffect(() => {
229
+ if (teamId && isCloudLoaded) {
230
+ const timerId = setTimeout(() => {
231
+ // THE FIX: Pass comprehensiveSettings instead of advancedSettings!
232
+ saveSettingsToCloud(teamId, quickSettings, comprehensiveSettings);
233
+ }, 500);
234
+ return () => clearTimeout(timerId);
235
+ }
236
+ // THE FIX: Watch comprehensiveSettings in the dependency array!
237
+ }, [quickSettings, comprehensiveSettings, teamId, isCloudLoaded]);
238
+
239
+ const getValidLayout = (players, gw) => {
240
+ if (!players || players.length !== 15) return null;
241
+ const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${gw}_Pts`]) || 0);
242
+
243
+ let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getEV(b) - getEV(a));
244
+ let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getEV(b) - getEV(a));
245
+ let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getEV(b) - getEV(a));
246
+ let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getEV(b) - getEV(a));
247
+
248
+ const starters = [];
249
+ if (gks.length) starters.push(gks.shift());
250
+ starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1));
251
+
252
+ const remaining = [...defs, ...mids, ...fwds].sort((a, b) => getEV(b) - getEV(a));
253
+ starters.push(...remaining.splice(0, 11 - starters.length));
254
+
255
+ const finalStarters = [
256
+ ...starters.filter((p) => p.Pos === "G"),
257
+ ...starters.filter((p) => p.Pos === "D"),
258
+ ...starters.filter((p) => p.Pos === "M"),
259
+ ...starters.filter((p) => p.Pos === "F"),
260
+ ];
261
+
262
+ const benchGk = gks.length ? gks[0] : null;
263
+ const benchRest = remaining.sort((a, b) => getEV(b) - getEV(a));
264
+ const bench = benchGk ? [benchGk, ...benchRest] : benchRest;
265
+ const topStarters = [...finalStarters].sort((a, b) => getEV(b) - getEV(a));
266
+
267
+ return { optimalArray: [...finalStarters, ...bench], cap: topStarters[0]?.ID, vice: topStarters[1]?.ID };
268
+ };
269
+
270
+ const derivedItb = useMemo(() => {
271
+ let currentBank = baselineItb;
272
+ if (!availableGWs || availableGWs.length === 0) return currentBank;
273
+ for (let gw = availableGWs[0]; gw <= activeGW; gw++) {
274
+ if (gw < activeGW && chipsByGw[gw] === "fh") continue;
275
+ if (transfersByGw[gw]) currentBank += transfersByGw[gw].netDelta || 0;
276
+ }
277
+ return currentBank;
278
+ }, [activeGW, availableGWs, baselineItb, transfersByGw, chipsByGw]);
279
+
280
+ const currentRemainingFts = useMemo(() => {
281
+ if (!availableGWs || availableGWs.length === 0) return baselineFt;
282
+ const startingFts = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
283
+ const usedThisWeek = transfersByGw[activeGW]?.count || 0;
284
+ return Math.max(0, startingFts - usedThisWeek);
285
+ }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw, ftAtStartOfGw]);
286
+
287
+ useEffect(() => {
288
+ fetch("https://anayshukla-fpl-solver.hf.space/api/fixtures").then((res) => res.json()).then(setFixtures).catch(() => { });
289
+ fetch("https://anayshukla-fpl-solver.hf.space/api/solver/default-settings").then((r) => (r.ok ? r.json() : {})).then((d) => {
290
+ if (d && typeof d === "object") {
291
+ setComprehensiveSettings(prev => ({ ...d, ...prev }));
292
+ }
293
+ }).catch(() => { });
294
+ }, []);
295
+
296
+ useEffect(() => { setItb(derivedItb); setAvailableFts(currentRemainingFts); }, [derivedItb, currentRemainingFts, setItb, setAvailableFts]);
297
+ useEffect(() => { if (horizon > maxAvailableHorizon && maxAvailableHorizon > 0) setHorizon(maxAvailableHorizon); }, [maxAvailableHorizon, horizon]);
298
+
299
+ useEffect(() => {
300
+ if (!isSolving) { setSolveElapsedSec(0); return; }
301
+ const t0 = Date.now();
302
+ const id = setInterval(() => setSolveElapsedSec(Math.floor((Date.now() - t0) / 1000)), 250);
303
+ const prev = document.body.style.overflow;
304
+ document.body.style.overflow = "hidden";
305
+ return () => { clearInterval(id); document.body.style.overflow = prev; };
306
+ }, [isSolving]);
307
+
308
+ useEffect(() => {
309
+ if (isLoggedIn && userProfile.defaultTeamId && String(userProfile.defaultTeamId) === String(teamId) && availableGWs.length === 0 && !isLoading) {
310
+ fetchTeam(null, teamData.length > 0);
311
+ }
312
+ }, [isLoggedIn, userProfile.defaultTeamId, teamId, availableGWs.length, teamData.length]);
313
+
314
+ const fetchTeam = async (e, preserveState = false) => {
315
+ // If manually clicked by user, always wipe the slate clean
316
+ if (e) { e.preventDefault(); setManualOverrides({}); preserveState = false; }
317
+
318
+ if (!teamId) return;
319
+ setIsLoading(true); setError(null);
320
+ try {
321
+ const res = await fetch(`https://anayshukla-fpl-solver.hf.space/api/manager/${teamId}`);
322
+ if (!res.ok) throw new Error("Could not fetch team.");
323
+ const data = await res.json();
324
+
325
+ if (data.picks && data.picks.length > 0) {
326
+ // 1. ALWAYS populate the strict baseline vault and logic
327
+ pristineSquadRef.current = {};
328
+ data.picks.forEach(p => {
329
+ pristineSquadRef.current[p.ID] = { ...p };
330
+ });
331
+ setBaselineItb(data.in_the_bank || 0);
332
+ setBaselineFt(typeof data.free_transfers === "number" ? data.free_transfers : 1);
333
+ setInitialSquadIds(data.picks.map((p) => p.ID));
334
+
335
+ const gws = Object.keys(data.picks[0]).filter((k) => k.includes("_Pts")).map((k) => parseInt(k.split("_")[0])).sort((a, b) => a - b);
336
+ setAvailableGWs(gws);
337
+
338
+ // 2. ONLY overwrite the squad arrays if we are NOT loading from a saved DB Draft
339
+ if (!preserveState) {
340
+ setTransfersByGw({}); setHighlightTransferIds({}); setSolverTransferPairs({}); setSolverApplySnapshot(null); setChipsByGw({}); setChipSolveSolutions([]);
341
+ setActiveGW(gws[0]);
342
+ const opt = getValidLayout(data.picks, gws[0]);
343
+ if (opt) { setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); }
344
+ else { setTeamData(data.picks); }
345
+ } else {
346
+ // If preserving state, just ensure activeGW doesn't break if the draft lacked it
347
+ if (!activeGW) setActiveGW(gws[0]);
348
+ }
349
+
350
+ setLastLoadedId(teamId);
351
+ if (isLoggedIn && userProfile.defaultTeamId !== parseInt(teamId)) { setPendingTeamId(parseInt(teamId)); setShowIdPrompt(true); }
352
+ }
353
+ } catch (err) { setError(err.message); } finally { setIsLoading(false); }
354
+ };
355
+
356
+ useEffect(() => {
357
+ if (!teamData.length || !activeGW || teamData.some((p) => p.isBlank && !String(p.ID).startsWith("blank_"))) return;
358
+
359
+ const gwLock = manualOverrides[activeGW];
360
+
361
+ if (gwLock && gwLock.ids) {
362
+ let reconstructed = gwLock.ids.map((id) => {
363
+ if (String(id).startsWith("blank_")) {
364
+ const replaced = gwLock.manualTransfers?.[id];
365
+ return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced };
366
+ }
367
+
368
+ let found = hydratePlayer(id);
369
+ if (found && gwLock.manualTransfers && gwLock.manualTransfers[id]) {
370
+ found.replacedPlayer = gwLock.manualTransfers[id];
371
+ }
372
+ return found;
373
+ }).filter(Boolean);
374
+
375
+ if (reconstructed.length !== 15) {
376
+ if (globalPlayers.length > 0) {
377
+ setManualOverrides((prev) => { const n = { ...prev }; delete n[activeGW]; return n; });
378
+ }
379
+ return;
380
+ }
381
+
382
+ // THE FIX: Auto-optimize lineup safely AFTER global EVs finish recalculating!
383
+ if (pendingAutoReset) {
384
+ const opt = getValidLayout(reconstructed, activeGW);
385
+ if (opt) {
386
+ setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice } }));
387
+ setTeamData(opt.optimalArray);
388
+ setCaptainId(opt.cap);
389
+ setViceId(opt.vice);
390
+ }
391
+ setPendingAutoReset(false);
392
+ return;
393
+ }
394
+
395
+ let needsSub = false;
396
+ const getEV = (p) => Number(p[`${activeGW}_Pts`]) || 0;
397
+
398
+ for (let i = 0; i < 11; i++) {
399
+ const starter = reconstructed[i];
400
+ if (starter.isBlank || (getEV(starter) === 0 && (!gwLock.forcedZeros || !gwLock.forcedZeros.includes(starter.ID)))) {
401
+ const bestBenchIdx = [11, 12, 13, 14].find((bIdx) => {
402
+ const bPlayer = reconstructed[bIdx];
403
+ if (bPlayer.isBlank || getEV(bPlayer) <= 0) return false;
404
+
405
+ const tempStarters = [...reconstructed.slice(0, 11)];
406
+ tempStarters[i] = bPlayer;
407
+ const counts = { G: 0, D: 0, M: 0, F: 0 };
408
+ tempStarters.forEach(p => { if (p.Pos) counts[p.Pos]++; });
409
+
410
+ if (counts.G !== 1 || counts.D < 3 || counts.M < 2 || counts.F < 1) return false;
411
+ return true;
412
+ });
413
+
414
+ if (bestBenchIdx) {
415
+ const temp = reconstructed[i];
416
+ reconstructed[i] = reconstructed[bestBenchIdx];
417
+ reconstructed[bestBenchIdx] = temp;
418
+ needsSub = true;
419
+ }
420
+ }
421
+ }
422
+
423
+ if (needsSub) {
424
+ setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: reconstructed.map((p) => p.ID) } }));
425
+ }
426
+
427
+ setTeamData(reconstructed);
428
+ setCaptainId(gwLock.cap);
429
+ setViceId(gwLock.vice);
430
+
431
+ } else {
432
+ let deterministicIds = [...initialSquadIds];
433
+ if (availableGWs && availableGWs.length > 0) {
434
+ for (let gw = availableGWs[0]; gw < activeGW; gw++) {
435
+ if (manualOverrides[gw] && chipsByGw[gw] !== "fh") deterministicIds = manualOverrides[gw].ids;
436
+ }
437
+ }
438
+
439
+ const deterministicSquad = deterministicIds.map(id => hydratePlayer(id)).filter(Boolean);
440
+
441
+ const opt = getValidLayout(deterministicSquad, activeGW);
442
+ if (opt) {
443
+ setTeamData(opt.optimalArray);
444
+ setCaptainId(opt.cap);
445
+ setViceId(opt.vice);
446
+ }
447
+ }
448
+ }, [globalPlayers, activeGW, teamData.length, manualOverrides, pendingAutoReset]);
449
+
450
+ const activeGwEV = useMemo(() => {
451
+ if (!teamData.length || !activeGW) return 0;
452
+ const chip = chipsByGw[activeGW];
453
+ const capMult = chip === "tc" ? 3 : 2;
454
+ let total = 0;
455
+ teamData.slice(0, 11).forEach((p) => { if (!p.isBlank) total += (Number(p[`${activeGW}_Pts`]) || 0) * (p.ID === captainId ? capMult : 1); });
456
+
457
+ let ofIdx = 0;
458
+ teamData.slice(11, 15).forEach((p) => {
459
+ if (!p.isBlank) {
460
+ if (chip === "bb") {
461
+ total += (Number(p[`${activeGW}_Pts`]) || 0);
462
+ } else if (p.Pos === "G") {
463
+ total += (Number(p[`${activeGW}_Pts`]) || 0) * 0.04;
464
+ } else {
465
+ const bw = [0.17, 0.05, 0.02][ofIdx] || 0.02;
466
+ total += (Number(p[`${activeGW}_Pts`]) || 0) * bw;
467
+ ofIdx++;
468
+ }
469
+ }
470
+ });
471
+ return total - hitsThisGw * HIT_COST;
472
+ }, [teamData, activeGW, captainId, hitsThisGw, chipsByGw]);
473
+
474
+ const horizonEvData = useMemo(() => {
475
+ if (!teamData.length || !horizonGWs.length) return { total: 0, breakdown: {} };
476
+ let total = 0;
477
+ const breakdown = {};
478
+
479
+ // Helper to get the actual squad that existed at the start of a specific GW
480
+ const getSquadForGw = (targetGw) => {
481
+ // If it's the active GW or in the future, the current teamData is our baseline
482
+ if (targetGw >= activeGW) return teamData;
483
+
484
+ // If it's in the past, rebuild it from the permanent chain
485
+ let deterministicIds = [...initialSquadIds];
486
+ if (availableGWs && availableGWs.length > 0) {
487
+ for (let gw = availableGWs[0]; gw <= targetGw; gw++) {
488
+ if (manualOverrides[gw] && chipsByGw[gw] !== "fh") {
489
+ deterministicIds = manualOverrides[gw].ids;
490
+ }
491
+ }
492
+ }
493
+ return deterministicIds.map(id => hydratePlayer(id)).filter(Boolean);
494
+ };
495
+
496
+ horizonGWs.forEach((gw) => {
497
+ let gwPts = 0;
498
+ const gwChip = chipsByGw[gw];
499
+ const gwCapMult = gwChip === "tc" ? 3 : 2;
500
+
501
+ const applyBenchMath = (benchSlice) => {
502
+ let ofIdx = 0;
503
+ benchSlice.forEach((p) => {
504
+ if (!p.isBlank) {
505
+ if (gwChip === "bb") {
506
+ gwPts += (Number(p[`${gw}_Pts`]) || 0);
507
+ } else if (p.Pos === "G") {
508
+ gwPts += (Number(p[`${gw}_Pts`]) || 0) * 0.04;
509
+ } else {
510
+ const bw = [0.17, 0.05, 0.02][ofIdx] || 0.02;
511
+ gwPts += (Number(p[`${gw}_Pts`]) || 0) * bw;
512
+ ofIdx++;
513
+ }
514
+ }
515
+ });
516
+ };
517
+
518
+ // THE FIX: Use the time-accurate squad for this specific GW
519
+ const gwSpecificSquad = getSquadForGw(gw);
520
+
521
+ if (gw === activeGW) {
522
+ gwSpecificSquad.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === captainId ? gwCapMult : 1); });
523
+ applyBenchMath(gwSpecificSquad.slice(11, 15));
524
+ } else {
525
+ const gwLock = manualOverrides[gw];
526
+ if (gwLock && gwLock.ids) {
527
+ const reconstructed = gwLock.ids.map((id) => gwSpecificSquad.find((p) => String(p.ID) === String(id)) || globalPlayers.find((p) => String(p.ID) === String(id))).filter(Boolean);
528
+ if (reconstructed.length === 15) {
529
+ reconstructed.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === gwLock.cap ? gwCapMult : 1); });
530
+ applyBenchMath(reconstructed.slice(11, 15));
531
+ } else {
532
+ const opt = getValidLayout(gwSpecificSquad, gw);
533
+ if (opt) {
534
+ opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); });
535
+ applyBenchMath(opt.optimalArray.slice(11, 15));
536
+ }
537
+ }
538
+ } else {
539
+ const opt = getValidLayout(gwSpecificSquad, gw);
540
+ if (opt) {
541
+ opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); });
542
+ applyBenchMath(opt.optimalArray.slice(11, 15));
543
+ }
544
+ }
545
+ }
546
+ const ftStart = ftAtStartOfGw(gw, availableGWs, baselineFt, transfersByGw, chipsByGw);
547
+ const T = transfersByGw[gw]?.count ?? 0;
548
+ const isChipFree = gwChip === "wc" || gwChip === "fh";
549
+ const hits = isChipFree ? 0 : Math.max(0, T - ftStart);
550
+ const ev = gwPts - hits * HIT_COST;
551
+
552
+ total += ev;
553
+ breakdown[gw] = { ev, chip: gwChip, hits, ftStart, moves: T, isChipFree };
554
+ });
555
+ return { total, breakdown };
556
+ }, [teamData, horizonGWs, activeGW, captainId, manualOverrides, baselineFt, transfersByGw, chipsByGw, globalPlayers, initialSquadIds, availableGWs]);
557
+
558
+ const horizonEV = horizonEvData.total;
559
+
560
+ // Sync the breakdown to the active draft automatically
561
+ useEffect(() => {
562
+ if (Object.keys(horizonEvData.breakdown).length === 0) return;
563
+ setDrafts(prev => {
564
+ const activeIdx = prev.findIndex(d => d.id === activeDraftId);
565
+ if (activeIdx === -1) return prev;
566
+ const currentCached = prev[activeIdx].cachedEvs;
567
+ if (JSON.stringify(currentCached) === JSON.stringify(horizonEvData.breakdown)) return prev;
568
+
569
+ const next = [...prev];
570
+ next[activeIdx] = { ...next[activeIdx], cachedEvs: horizonEvData.breakdown };
571
+ return next;
572
+ });
573
+ }, [horizonEvData.breakdown, activeDraftId, setDrafts]);
574
+
575
+ // --- SOLVER API TRIGGERS & BASELINE ENGINE ---
576
+ const getSolverStartingState = () => {
577
+ const startGW = solveGWs[0];
578
+ const startIndex = availableGWs.indexOf(startGW);
579
+
580
+ // 1. Get Starting Squad (The exact squad going INTO the solve horizon, before current manual moves)
581
+ let startingIds = initialSquadIds;
582
+ if (startIndex > 0) {
583
+ const prevGw = availableGWs[startIndex - 1];
584
+ startingIds = manualOverrides[prevGw]?.ids || initialSquadIds;
585
+ }
586
+
587
+ const startingSquad = startingIds.map(id => {
588
+ // Try to keep exact FPL prices from current teamData
589
+ const existing = teamData.find(t => String(t.ID) === String(id));
590
+ if (existing) return existing;
591
+ const g = globalPlayers.find(x => String(x.ID) === String(id));
592
+ return g ? { ...g, Price: getPlayerPrice(g) } : null;
593
+ }).filter(Boolean);
594
+
595
+ // 2. Get Starting ITB (Bank BEFORE the current gameweek's moves)
596
+ let startingItb = baselineItb;
597
+ for (let i = 0; i < startIndex; i++) {
598
+ const gw = availableGWs[i];
599
+ if (chipsByGw[gw] === "fh") continue;
600
+ if (transfersByGw[gw]) startingItb += transfersByGw[gw].netDelta || 0;
601
+ }
602
+
603
+ // 3. Get Starting FTs (FTs going INTO the horizon)
604
+ const startingFts = ftAtStartOfGw(startGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
605
+
606
+ // 4. Extract Manual Moves as Booked Transfers
607
+ const bookedTransfers = [];
608
+ solveGWs.forEach(gw => {
609
+ const lock = manualOverrides[gw];
610
+ if (lock && lock.manualTransfers) {
611
+ Object.entries(lock.manualTransfers).forEach(([inId, outPlayer]) => {
612
+ if (!String(inId).startsWith("blank_") && outPlayer) {
613
+ bookedTransfers.push({
614
+ gw: Number(gw),
615
+ transfer_in: Number(inId),
616
+ transfer_out: Number(outPlayer.ID)
617
+ });
618
+ }
619
+ });
620
+ }
621
+ });
622
+
623
+ return { startingSquad, startingItb, startingFts, bookedTransfers };
624
+ };
625
+
626
+
627
+ const getActiveCompSettings = (bookedTransfers) => {
628
+ let payload;
629
+
630
+ // If OFF: Send the absolute baseline defaults from our frontend UI + the manual moves
631
+ if (!comprehensiveSettings.enabled) {
632
+ payload = { ...DEFAULT_SETTINGS, booked_transfers: bookedTransfers };
633
+ }
634
+ // If ON: Send the user's custom edited settings + the manual moves
635
+ else {
636
+ payload = { ...comprehensiveSettings, booked_transfers: bookedTransfers };
637
+ }
638
+
639
+ // Safety check: If they aren't using the advanced FT list, strip it so backend uses the flat value
640
+ if (!payload.use_ft_value_list) {
641
+ delete payload.ft_value_list;
642
+ }
643
+
644
+ return payload;
645
+ };
646
+
647
+ // --- THE XMINS FILTER ENGINE ---
648
+ // Replicates open-fpl-solver logic BEFORE sending the payload to Python
649
+ const getFilteredGlobalPlayers = (startingSquad, bookedTransfers) => {
650
+ const activeSettings = getActiveCompSettings(bookedTransfers);
651
+ const xminLbPerGw = activeSettings.xmin_lb || 0;
652
+
653
+ // If the setting is 0 or disabled, skip filtering
654
+ if (xminLbPerGw <= 0) return globalPlayers;
655
+
656
+ // Multiply the input by the horizon length to get the total threshold
657
+ const totalXminThreshold = xminLbPerGw * horizonGWs.length;
658
+
659
+ // Build the "safe_players" array (Current squad + anyone you manually locked in)
660
+ const safePlayers = new Set(startingSquad.map(p => String(p.ID)));
661
+ bookedTransfers.forEach(bt => {
662
+ safePlayers.add(String(bt.transfer_in));
663
+ safePlayers.add(String(bt.transfer_out));
664
+ });
665
+
666
+ // Execute the filter: (total_min >= xmin_lb) | (ID in safe_players)
667
+ return globalPlayers.filter(p => {
668
+ if (safePlayers.has(String(p.ID))) return true;
669
+
670
+ let totalMins = 0;
671
+ horizonGWs.forEach(gw => {
672
+ totalMins += (Number(p[`${gw}_xMins`]) || 0);
673
+ });
674
+
675
+ return totalMins >= totalXminThreshold;
676
+ });
677
+ };
678
+
679
+ const runMainSolver = () => {
680
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
681
+
682
+ // THE FIX: Calculate apples-to-apples baseline EV for the active window, and the past locked EV
683
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
684
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
685
+
686
+ apiHandleSolve({
687
+ teamId, solveGWs, horizonGWs, teamData: startingSquad,
688
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
689
+ itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw,
690
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers),
691
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
692
+ });
693
+ };
694
+
695
+ const runSensAnalysis = () => {
696
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
697
+
698
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
699
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
700
+
701
+ apiHandleSensAnalysis({
702
+ teamId, solveGWs, horizonGWs, teamData: startingSquad,
703
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
704
+ itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw,
705
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers), numSims,
706
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
707
+ });
708
+ };
709
+
710
+ const runChipSolve = () => {
711
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
712
+
713
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
714
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
715
+
716
+ apiHandleChipSolve({
717
+ teamId, horizonGWs, teamData: startingSquad,
718
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
719
+ itb: startingItb, availableFts: startingFts, advancedSettings,
720
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers), chipSolveOptions,
721
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
722
+ });
723
+ };
724
+
725
+ // --- MULTIVERSE DRAFT HANDLERS ---
726
+ const handleCloneDraft = () => {
727
+ if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; }
728
+ const currentDraft = drafts.find(d => d.id === activeDraftId);
729
+ const newId = `draft_${Date.now()}`;
730
+
731
+ // THE FIX: Deep clone ALL objects to stop timelines from sharing memory
732
+ const newDraft = {
733
+ ...currentDraft,
734
+ id: newId,
735
+ name: `${currentDraft.name} (Copy)`,
736
+ fixtureOverrides: JSON.parse(JSON.stringify(currentDraft.fixtureOverrides || {})),
737
+ sessionEdits: JSON.parse(JSON.stringify(currentDraft.sessionEdits || {})),
738
+ manualOverrides: JSON.parse(JSON.stringify(currentDraft.manualOverrides || {})),
739
+ transfersByGw: JSON.parse(JSON.stringify(currentDraft.transfersByGw || {})),
740
+ highlightTransferIds: JSON.parse(JSON.stringify(currentDraft.highlightTransferIds || {})),
741
+ solverTransferPairs: JSON.parse(JSON.stringify(currentDraft.solverTransferPairs || {})),
742
+ chipsByGw: JSON.parse(JSON.stringify(currentDraft.chipsByGw || {})),
743
+ cachedEvs: JSON.parse(JSON.stringify(currentDraft.cachedEvs || {}))
744
+ };
745
+
746
+ setDrafts(prev => [...prev, newDraft]);
747
+ setActiveDraftId(newId);
748
+ };
749
+
750
+ const handleNewDraft = () => {
751
+ if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; }
752
+ const newId = `draft_${Date.now()}`;
753
+ const startGW = availableGWs[0] || activeGW;
754
+ const pristineSquad = initialSquadIds.map(id => hydratePlayer(id)).filter(Boolean);
755
+ const opt = getValidLayout(pristineSquad, startGW);
756
+ const finalSquad = opt ? opt.optimalArray : pristineSquad;
757
+
758
+ const newDraft = {
759
+ id: newId, name: `Draft ${drafts.length + 1}`, teamData: finalSquad, horizon: horizon, activeGW: startGW, captainId: opt ? opt.cap : null, viceId: opt ? opt.vice : null, solverTransferPairs: {}, solverApplySnapshot: null, appliedPlanSummary: null, hitsThisGw: 0, highlightTransferIds: {}, transfersByGw: {}, chipsByGw: {}, manualOverrides: {}, fixtureOverrides: {}, sessionEdits: {}, cachedEvs: {}
760
+ };
761
+ setDrafts(prev => [...prev, newDraft]);
762
+ setActiveDraftId(newId);
763
+ };
764
+ // --- TIMELINE WIPING HELPER ---
765
+ // If you manually edit the pitch, any "future" moves planned by the solver MUST be
766
+ // wiped out so that the timeline correctly cascades forward!
767
+ const clearFuture = (prev) => {
768
+ return prev; // 👈 FIXED: We no longer wipe out future gameweek plans!
769
+ };
770
+
771
+ const applySolution = (sol) => {
772
+ setSolverApplySnapshot({
773
+ teamData: [...teamData], availableFts, transfersByGw: { ...transfersByGw }, manualOverrides: { ...manualOverrides }, baselineItb, baselineFt
774
+ });
775
+
776
+ const newOverrides = { ...manualOverrides };
777
+ const newTransfersByGw = { ...transfersByGw };
778
+ const newChipsByGw = { ...chipsByGw };
779
+ const newHighlights = { ...highlightTransferIds };
780
+ const newPairs = { ...solverTransferPairs };
781
+
782
+ sol.plan.forEach(gwPlan => {
783
+ const gw = gwPlan.gw;
784
+ const getPts = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? (Number(p[`${gw}_Pts`]) || 0) : 0; };
785
+ const posOrder = { G: 1, D: 2, M: 3, F: 4 };
786
+ const getPos = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? posOrder[p.Pos] || 5 : 5; };
787
+
788
+ let activeLineup = [...gwPlan.lineup];
789
+ let activeBench = [...gwPlan.bench];
790
+ const isBB = sol.chips_used && sol.chips_used[String(gw)] === "bb";
791
+
792
+ // BUG 2 FIX: Auto-optimize the BB lineup visually so best players start
793
+ if (isBB) {
794
+ const all15 = [...activeLineup, ...activeBench];
795
+ const pObjs = all15.map(id => {
796
+ const p = globalPlayers.find(x => String(x.ID) === String(id));
797
+ return p ? { ...p, temp_pts: getPts(id) } : { ID: id, Pos: 'M', temp_pts: 0 };
798
+ });
799
+
800
+ let gks = pObjs.filter(p => p.Pos === "G").sort((a, b) => b.temp_pts - a.temp_pts);
801
+ let defs = pObjs.filter(p => p.Pos === "D").sort((a, b) => b.temp_pts - a.temp_pts);
802
+ let mids = pObjs.filter(p => p.Pos === "M").sort((a, b) => b.temp_pts - a.temp_pts);
803
+ let fwds = pObjs.filter(p => p.Pos === "F").sort((a, b) => b.temp_pts - a.temp_pts);
804
+
805
+ const starters = [];
806
+ if (gks.length) starters.push(gks.shift());
807
+ starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1));
808
+
809
+ const remaining = [...defs, ...mids, ...fwds].sort((a, b) => b.temp_pts - a.temp_pts);
810
+ starters.push(...remaining.splice(0, 11 - starters.length));
811
+
812
+ activeLineup = starters.map(p => p.ID);
813
+ activeBench = [gks[0]?.ID, ...remaining.map(p => p.ID)].filter(Boolean);
814
+ }
815
+
816
+ const sortedLineup = activeLineup.sort((a, b) => {
817
+ const posDiff = getPos(a) - getPos(b);
818
+ if (posDiff !== 0) return posDiff;
819
+ return getPts(b) - getPts(a);
820
+ });
821
+
822
+ newOverrides[gw] = { ids: [...sortedLineup, ...activeBench], cap: gwPlan.captain, vice: gwPlan.vice_captain, forcedZeros: [] };
823
+ if (sol.chips_used && sol.chips_used[String(gw)]) newChipsByGw[gw] = sol.chips_used[String(gw)];
824
+
825
+ if (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) {
826
+ const netDelta = gwPlan.transfers_out.reduce((sum, id) => {
827
+ const squadP = teamData.find(p => String(p.ID) === String(id));
828
+ return sum + (squadP && squadP.Price ? squadP.Price : getPlayerPrice(globalPlayers.find(p => String(p.ID) === String(id))));
829
+ }, 0) - gwPlan.transfers_in.reduce((sum, id) => sum + (getPlayerPrice(globalPlayers.find(p => String(p.ID) === String(id))) || 0), 0);
830
+
831
+ newTransfersByGw[gw] = { count: gwPlan.transfers_in.length, hits: gwPlan.hits, netDelta: netDelta, inIds: gwPlan.transfers_in, outIds: gwPlan.transfers_out };
832
+ newHighlights[gw] = [...gwPlan.transfers_in];
833
+ const newManualTransfersForGw = {};
834
+ gwPlan.transfers_in.forEach((inId, idx) => {
835
+ const outId = gwPlan.transfers_out[idx];
836
+
837
+ const preSolveP = teamData.find(p => String(p.ID) === String(outId));
838
+ let outP;
839
+
840
+ if (preSolveP) {
841
+ outP = preSolveP;
842
+ } else {
843
+ const gMatch = globalPlayers.find(p => String(p.ID) === String(outId));
844
+ const marketCost = gMatch ? (gMatch.now_cost !== undefined ? gMatch.now_cost : gMatch.Price) : 0;
845
+ outP = gMatch ? { ...gMatch, purchase_price: marketCost, selling_price: marketCost, Price: marketCost } : null;
846
+ }
847
+
848
+ if (outP) {
849
+ const finalOutPlayer = { ...outP, Price: getPlayerPrice(outP) };
850
+ newManualTransfersForGw[inId] = finalOutPlayer;
851
+ }
852
+ });
853
+
854
+ // THE FIX: Delete the solver memory so it doesn't double-render alongside the manual memory!
855
+ delete newPairs[gw];
856
+
857
+ // CLEAN FIX: Attach the transfers directly to the master object we are building
858
+ // No more setState calls fighting each other inside a loop!
859
+ newOverrides[gw] = {
860
+ ...newOverrides[gw],
861
+ manualTransfers: {
862
+ ...(newOverrides[gw]?.manualTransfers || {}),
863
+ ...newManualTransfersForGw
864
+ }
865
+ };
866
+
867
+ // Chips are now handled properly too
868
+ if (gwPlan.chip) newChipsByGw[gw] = gwPlan.chip;
869
+
870
+ } else {
871
+ delete newTransfersByGw[gw];
872
+ delete newHighlights[gw];
873
+ delete newPairs[gw];
874
+ }
875
+ });
876
+
877
+ setManualOverrides(newOverrides); setTransfersByGw(newTransfersByGw); setChipsByGw(newChipsByGw); setHighlightTransferIds(newHighlights); setSolverTransferPairs(newPairs);
878
+
879
+ setAppliedPlanSummary({
880
+ horizon: `GW${sol.horizon_gws[0]} - GW${sol.horizon_gws[sol.horizon_gws.length - 1]}`,
881
+ ev: sol.ev,
882
+ objectiveScore: sol.objective_score,
883
+ plan: sol.plan,
884
+ lockedBaselineEv: horizonEV,
885
+ transfers: sol.plan.map(p => ({
886
+ gw: p.gw, chip: p.chip, itb: p.itb, hits: p.hits, ft_at_start: p.ft_at_start,
887
+ outs: p.transfers_out.map(id => globalPlayers.find(x => x.ID === id)?.Name || id),
888
+ ins: p.transfers_in.map(id => globalPlayers.find(x => x.ID === id)?.Name || id)
889
+ }))
890
+ });
891
+
892
+ if (sol.plan.length > 0) {
893
+ const activePlan = sol.plan.find(p => p.gw === activeGW) || sol.plan[0];
894
+ let nextSquad = [...teamData];
895
+ if (activePlan.transfers_in.length > 0) {
896
+ activePlan.transfers_in.forEach((inId, idx) => {
897
+ const pIn = globalPlayers.find(p => String(p.ID) === String(inId));
898
+ const outIndex = nextSquad.findIndex(p => String(p.ID) === String(activePlan.transfers_out[idx]));
899
+ if (outIndex !== -1 && pIn) nextSquad[outIndex] = { ...pIn, Price: getPlayerPrice(pIn) };
900
+ });
901
+ } else {
902
+ nextSquad = [...activePlan.lineup, ...activePlan.bench].map(id => {
903
+ const existing = teamData.find(t => String(t.ID) === String(id));
904
+ const hydrated = hydratePlayer(id);
905
+ if (existing && hydrated) return { ...hydrated, replacedPlayer: existing.replacedPlayer };
906
+ return hydrated;
907
+ }).filter(Boolean);
908
+ }
909
+
910
+ const getPts = (p) => Number(p[`${activePlan.gw}_Pts`]) || 0;
911
+ const finalLineup = activePlan.lineup.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean);
912
+ const finalBench = activePlan.bench.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean);
913
+
914
+ const sortedStarters = [
915
+ ...finalLineup.filter(p => p.Pos === "G").sort((a, b) => getPts(b) - getPts(a)),
916
+ ...finalLineup.filter(p => p.Pos === "D").sort((a, b) => getPts(b) - getPts(a)),
917
+ ...finalLineup.filter(p => p.Pos === "M").sort((a, b) => getPts(b) - getPts(a)),
918
+ ...finalLineup.filter(p => p.Pos === "F").sort((a, b) => getPts(b) - getPts(a)),
919
+ ];
920
+ const sortedBench = [
921
+ ...finalBench.filter(p => p.Pos === "G"),
922
+ ...finalBench.filter(p => p.Pos !== "G").sort((a, b) => getPts(b) - getPts(a))
923
+ ];
924
+
925
+ setTeamData([...sortedStarters, ...sortedBench]);
926
+ setCaptainId(activePlan.captain); setViceId(activePlan.vice_captain);
927
+ }
928
+ setPendingSolutions([]);
929
+ };
930
+
931
+ const updateFutureTimelines = (oldSquad, newSquad, currentOverrides, currentTransfers, currentPairs, customMapping = null) => {
932
+ let mapping = {};
933
+ let removedIds = [];
934
+ let addedIds = [];
935
+
936
+ if (customMapping) {
937
+ Object.keys(customMapping).forEach(k => {
938
+ mapping[String(k)] = String(customMapping[k]);
939
+ removedIds.push(String(k));
940
+ addedIds.push(String(customMapping[k]));
941
+ });
942
+ } else {
943
+ const oldIds = oldSquad.map(p => String(p.ID));
944
+ const newIds = newSquad.map(p => String(p.ID));
945
+ removedIds = oldIds.filter(id => !newIds.includes(id) && !id.startsWith("blank_"));
946
+ addedIds = newIds.filter(id => !oldIds.includes(id) && !id.startsWith("blank_"));
947
+ for (let i = 0; i < Math.min(removedIds.length, addedIds.length); i++) {
948
+ mapping[removedIds[i]] = addedIds[i];
949
+ }
950
+ }
951
+
952
+ const nextOverrides = { ...currentOverrides };
953
+ const nextTransfers = { ...currentTransfers };
954
+ const nextPairs = { ...currentPairs };
955
+
956
+ for (let gw = activeGW + 1; gw <= Math.max(...(availableGWs || [])); gw++) {
957
+
958
+ // 1. SURGICAL SCRUB: Remove redundant "Buy" plans to kill the ghost button,
959
+ // but DO NOT filter "outIds" so the Y->Z to X->Z cascade survives perfectly!
960
+ if (nextTransfers[gw]) {
961
+ nextTransfers[gw].inIds = (nextTransfers[gw].inIds || []).filter(id => !addedIds.includes(String(id)));
962
+ nextTransfers[gw].count = nextTransfers[gw].inIds.length;
963
+ if (nextTransfers[gw].count === 0) delete nextTransfers[gw];
964
+ }
965
+
966
+ if (nextOverrides[gw]) {
967
+ const lock = nextOverrides[gw];
968
+ const updatedIds = lock.ids.map(id => mapping[String(id)] || String(id));
969
+
970
+ // Anti-Time-Paradox: Only wipe the GW if the cascade creates literal duplicate players
971
+ const uniqueIds = new Set(updatedIds);
972
+ if (uniqueIds.size !== updatedIds.length) {
973
+ delete nextOverrides[gw];
974
+ delete nextTransfers[gw];
975
+ delete nextPairs[gw];
976
+
977
+ // THE FIX: Plunge the timeline into darkness so the UI doesn't glow for a deleted GW!
978
+ setHighlightTransferIds(prev => { const n = { ...prev }; delete n[gw]; return n; });
979
+ setTransfersByGw(prev => { const n = { ...prev }; delete n[gw]; return n; });
980
+
981
+ continue;
982
+ }
983
+
984
+ const updatedTransfers = {};
985
+ if (lock.manualTransfers) {
986
+ for (const [inId, outPlayer] of Object.entries(lock.manualTransfers)) {
987
+ // KILL OBSOLETE MOVES & GLOWS: Skip this move if the player is already naturally in the incoming squad
988
+ if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) {
989
+
990
+ // FIX: highlightTransferIds is an object of arrays. Target the specific GW array.
991
+ setHighlightTransferIds(prev => ({
992
+ ...prev,
993
+ [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId))
994
+ }));
995
+
996
+ // FIX: transfersByGw is an object of objects. Safely reduce the count.
997
+ setTransfersByGw(prev => {
998
+ const currentGwTransfers = prev[gw];
999
+ if (!currentGwTransfers) return prev;
1000
+
1001
+ const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId));
1002
+ const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1);
1003
+
1004
+ if (newCount === 0) {
1005
+ const next = { ...prev };
1006
+ delete next[gw];
1007
+ return next;
1008
+ }
1009
+ return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } };
1010
+ });
1011
+
1012
+ continue;
1013
+ }
1014
+
1015
+ let newOutPlayer = outPlayer;
1016
+ const outIdStr = String(outPlayer?.ID);
1017
+ if (outPlayer && mapping[outIdStr]) {
1018
+ const mappedId = mapping[outIdStr];
1019
+ let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId);
1020
+ if (mappedP) newOutPlayer = { ...mappedP, Price: getPlayerPrice(mappedP) };
1021
+ }
1022
+ updatedTransfers[mapping[String(inId)] || String(inId)] = newOutPlayer;
1023
+ }
1024
+ }
1025
+
1026
+ // --- RE-OPTIMIZE LINEUP FOR FUTURE GAMEWEEKS ---
1027
+ // Instantly sub out the cascaded player if their EV is bad in this future gameweek
1028
+ const reconstructedSquad = updatedIds.map(id => {
1029
+ if (String(id).startsWith("blank_")) {
1030
+ const replaced = updatedTransfers[id];
1031
+ return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced };
1032
+ }
1033
+ return hydratePlayer(id);
1034
+ }).filter(Boolean);
1035
+
1036
+ const opt = getValidLayout(reconstructedSquad, gw);
1037
+
1038
+ nextOverrides[gw] = {
1039
+ ...lock,
1040
+ ids: opt ? opt.optimalArray.map(p => p.ID) : updatedIds,
1041
+ manualTransfers: updatedTransfers,
1042
+ cap: opt ? opt.cap : mapping[String(lock.cap)] || lock.cap,
1043
+ vice: opt ? opt.vice : mapping[String(lock.vice)] || lock.vice
1044
+ };
1045
+ }
1046
+
1047
+ if (nextPairs[gw]) {
1048
+ const updatedGwPairs = {};
1049
+ for (const [inId, pairData] of Object.entries(nextPairs[gw])) {
1050
+ // KILL GHOST BUTTON & GLOW: Skip this solver memory if the player naturally returns to squad
1051
+ if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) {
1052
+ setHighlightTransferIds(prev => ({ ...prev, [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId)) }));
1053
+ setTransfersByGw(prev => {
1054
+ const currentGwTransfers = prev[gw];
1055
+ if (!currentGwTransfers) return prev;
1056
+ const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId));
1057
+ const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1);
1058
+ if (newCount === 0) { const next = { ...prev }; delete next[gw]; return next; }
1059
+ return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } };
1060
+ });
1061
+ continue;
1062
+ }
1063
+
1064
+ let newOut = pairData.outPlayer;
1065
+ const outIdStr = String(newOut?.ID);
1066
+ if (newOut && mapping[outIdStr]) {
1067
+ const mappedId = mapping[outIdStr];
1068
+ let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId);
1069
+ if (mappedP) newOut = { ...mappedP, Price: getPlayerPrice(mappedP) };
1070
+ }
1071
+ updatedGwPairs[mapping[String(inId)] || String(inId)] = { outPlayer: newOut };
1072
+ }
1073
+
1074
+ if (Object.keys(updatedGwPairs).length === 0) {
1075
+ delete nextPairs[gw];
1076
+ } else {
1077
+ nextPairs[gw] = updatedGwPairs;
1078
+ }
1079
+ }
1080
+ }
1081
+ return { nextOverrides, nextTransfers, nextPairs };
1082
+ };
1083
+
1084
+ const handleDragStart = (event) => setActiveDragPlayer(event.active.data.current.player);
1085
+
1086
+ const isValidSwap = (p1, p2) => {
1087
+ if (!p1 || !p2 || p1.isBlank || p2.isBlank) return false;
1088
+ if (p1.ID === p2.ID) return true;
1089
+ if (p1.Pos === "G" && p2.Pos !== "G") return false;
1090
+ if (p1.Pos !== "G" && p2.Pos === "G") return false;
1091
+ const currentStarters = teamData.slice(0, 11);
1092
+ const isP1Starter = currentStarters.some((p) => p.ID === p1.ID);
1093
+ const isP2Starter = currentStarters.some((p) => p.ID === p2.ID);
1094
+ if (isP1Starter === isP2Starter) return true;
1095
+ const newStarters = currentStarters.filter((p) => p.ID !== p1.ID && p.ID !== p2.ID);
1096
+ newStarters.push(isP1Starter ? p2 : p1);
1097
+ const counts = { G: 0, D: 0, M: 0, F: 0 };
1098
+ newStarters.forEach((p) => counts[p.Pos]++);
1099
+ return counts.G === 1 && counts.D >= 3 && counts.M >= 2 && counts.F >= 1 && newStarters.length === 11;
1100
+ };
1101
+
1102
+ const handleDragEnd = (event) => {
1103
+ const { active, over } = event;
1104
+ setActiveDragPlayer(null);
1105
+ if (over && active.id !== over.id) {
1106
+ const p1 = active.data.current.player; const p2 = over.data.current.player;
1107
+ if (isValidSwap(p1, p2)) {
1108
+ const newArr = [...teamData];
1109
+ const idx1 = newArr.findIndex((p) => p.ID === p1.ID);
1110
+ const idx2 = newArr.findIndex((p) => p.ID === p2.ID);
1111
+ newArr[idx1] = p2; newArr[idx2] = p1;
1112
+ const normalized = idx1 >= 11 || idx2 >= 11 ? normalizeBenchGkFirst(newArr, activeGW) : newArr;
1113
+ const forcedZeros = manualOverrides[activeGW]?.forcedZeros || [];
1114
+ if ((Number(p1[`${activeGW}_Pts`]) || 0) === 0 && idx2 < 11) forcedZeros.push(p1.ID);
1115
+ if ((Number(p2[`${activeGW}_Pts`]) || 0) === 0 && idx1 < 11) forcedZeros.push(p2.ID);
1116
+
1117
+ let newCap = captainId; let newVice = viceId;
1118
+ const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${activeGW}_Pts`]) || 0);
1119
+ const starters = normalized.slice(0, 11);
1120
+ const starterIds = starters.map((p) => p.ID);
1121
+
1122
+ if (!starterIds.includes(newCap)) {
1123
+ const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a));
1124
+ newCap = sorted[0]?.ID;
1125
+ if (newCap === newVice) newVice = sorted[1]?.ID;
1126
+ }
1127
+ if (!starterIds.includes(newVice)) {
1128
+ const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a));
1129
+ newVice = sorted.find((p) => p.ID !== newCap)?.ID;
1130
+ }
1131
+
1132
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: normalized.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros } }));
1133
+ setTeamData(normalized);
1134
+
1135
+ setTransfersByGw(clearFuture);
1136
+ setHighlightTransferIds(clearFuture);
1137
+ setSolverTransferPairs(clearFuture);
1138
+ setChipsByGw(clearFuture);
1139
+ setAppliedPlanSummary(null);
1140
+ }
1141
+ }
1142
+ };
1143
+
1144
+ const handleCapChange = (id, type) => {
1145
+ let newCap = captainId; let newVice = viceId;
1146
+ if (type === "C") { newCap = id; if (viceId === id) newVice = captainId; } else { newVice = id; if (captainId === id) newCap = viceId; }
1147
+
1148
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: teamData.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros: prev[activeGW]?.forcedZeros } }));
1149
+ setCaptainId(newCap); setViceId(newVice);
1150
+
1151
+ setTransfersByGw(clearFuture);
1152
+ setHighlightTransferIds(clearFuture);
1153
+ setSolverTransferPairs(clearFuture);
1154
+ setChipsByGw(clearFuture);
1155
+ setAppliedPlanSummary(null);
1156
+ };
1157
+
1158
+ const handleResetGW = () => {
1159
+ const opt = getValidLayout(teamData, activeGW);
1160
+ if (!opt) return;
1161
+
1162
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice, forcedZeros: prev[activeGW]?.forcedZeros || [] } }));
1163
+ setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice);
1164
+
1165
+ setTransfersByGw(clearFuture);
1166
+ setHighlightTransferIds(clearFuture);
1167
+ setSolverTransferPairs(clearFuture);
1168
+ setChipsByGw(clearFuture);
1169
+ setAppliedPlanSummary(null);
1170
+ };
1171
+
1172
+ const handleChipSelect = (gw, chipType) => {
1173
+ setChipsByGw((prev) => {
1174
+ const next = { ...prev };
1175
+ if (!chipType) { delete next[gw]; } else { Object.keys(next).forEach((g) => { if (next[g] === chipType) delete next[g]; }); next[gw] = chipType; }
1176
+ return clearFuture(next);
1177
+ });
1178
+
1179
+ setManualOverrides(clearFuture);
1180
+ setTransfersByGw(clearFuture);
1181
+ setHighlightTransferIds(clearFuture);
1182
+ setSolverTransferPairs(clearFuture);
1183
+ setAppliedPlanSummary(null);
1184
+ };
1185
+
1186
+ const handleTransferOut = (playerToDrop) => {
1187
+ const sellPrice = getPlayerPrice(playerToDrop);
1188
+ const blankId = `blank_${Date.now()}`;
1189
+ const newSquad = teamData.map((p) => String(p.ID) === String(playerToDrop.ID) ? { ID: blankId, isBlank: true, Pos: p.Pos, Name: "", Team: "", Price: 0, replacedPlayer: playerToDrop } : p);
1190
+
1191
+ const opt = getValidLayout(newSquad, activeGW);
1192
+ const finalSquad = opt ? opt.optimalArray : newSquad;
1193
+
1194
+ let nextTransfers = { ...transfersByGw };
1195
+ nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), netDelta: (nextTransfers[activeGW]?.netDelta || 0) + sellPrice };
1196
+
1197
+ let nextOverrides = { ...manualOverrides };
1198
+ nextOverrides[activeGW] = {
1199
+ ...(nextOverrides[activeGW] || {}), ids: finalSquad.map(p => p.ID),
1200
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
1201
+ manualTransfers: { ...(nextOverrides[activeGW]?.manualTransfers || {}), [blankId]: playerToDrop }
1202
+ };
1203
+
1204
+ const mapping = { [playerToDrop.ID]: blankId };
1205
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
1206
+
1207
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
1208
+ setHighlightTransferIds(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null);
1209
+ };
1210
+
1211
+ const handleAddPlayer = (newPlayer) => {
1212
+ const cost = getPlayerPrice(newPlayer);
1213
+ if (itb < cost) return alert("Insufficient funds!");
1214
+
1215
+ const newSquad = teamData.map((p) => String(p.ID) === String(selectedPlayer.ID) ? { ...newPlayer, Price: cost, purchase_price: newPlayer.now_cost, selling_price: newPlayer.now_cost, replacedPlayer: selectedPlayer.replacedPlayer } : p);
1216
+ const opt = getValidLayout(newSquad, activeGW);
1217
+ const finalSquad = opt ? opt.optimalArray : newSquad;
1218
+
1219
+ let nextTransfers = { ...transfersByGw };
1220
+ nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), count: (nextTransfers[activeGW]?.count || 0) + 1, netDelta: (nextTransfers[activeGW]?.netDelta || 0) - cost };
1221
+
1222
+ const newManualTransfers = { ...(manualOverrides[activeGW]?.manualTransfers || {}) };
1223
+ delete newManualTransfers[selectedPlayer.ID];
1224
+ if (selectedPlayer.replacedPlayer) newManualTransfers[newPlayer.ID] = selectedPlayer.replacedPlayer;
1225
+
1226
+ let nextOverrides = { ...manualOverrides };
1227
+ nextOverrides[activeGW] = {
1228
+ ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID),
1229
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
1230
+ forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers
1231
+ };
1232
+
1233
+ const mapping = { [selectedPlayer.ID]: newPlayer.ID };
1234
+ if (selectedPlayer.replacedPlayer) mapping[selectedPlayer.replacedPlayer.ID] = newPlayer.ID;
1235
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
1236
+
1237
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
1238
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
1239
+ setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: [...(prev[activeGW] || []), newPlayer.ID] }));
1240
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); setSearchQuery("");
1241
+ };
1242
+
1243
+ const handleUndoTransfer = (e, currentId, replacedPlayer) => {
1244
+ e.stopPropagation();
1245
+ const buyPlayer = teamData.find((p) => String(p.ID) === String(currentId)) || globalPlayers.find((p) => String(p.ID) === String(currentId));
1246
+ const buy = (!String(currentId).startsWith("blank_") && buyPlayer) ? getPlayerPrice(buyPlayer) : 0;
1247
+ const sell = getPlayerPrice(replacedPlayer);
1248
+
1249
+ // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup
1250
+ // const freshReplacedPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(replacedPlayer.ID)) || replacedPlayer), Price: getPlayerPrice(replacedPlayer) };
1251
+ const freshReplacedPlayer = hydratePlayer(replacedPlayer.ID, replacedPlayer) || replacedPlayer;
1252
+
1253
+ const newSquad = teamData.map((p) => (String(p.ID) === String(currentId) ? freshReplacedPlayer : p));
1254
+ const opt = getValidLayout(newSquad, activeGW);
1255
+ const finalSquad = opt ? opt.optimalArray : newSquad;
1256
+
1257
+ let nextTransfers = { ...transfersByGw };
1258
+ const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 };
1259
+ nextTransfers[activeGW] = { ...row, count: Math.max(0, row.count - (!String(currentId).startsWith("blank_") ? 1 : 0)), netDelta: row.netDelta - (sell - buy) };
1260
+
1261
+ let nextOverrides = { ...manualOverrides };
1262
+ const newManualTransfers = { ...(nextOverrides[activeGW]?.manualTransfers || {}) };
1263
+ delete newManualTransfers[currentId];
1264
+
1265
+ nextOverrides[activeGW] = {
1266
+ ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID),
1267
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
1268
+ forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers
1269
+ };
1270
+
1271
+ const mapping = { [currentId]: replacedPlayer.ID };
1272
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
1273
+
1274
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
1275
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
1276
+ setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: Array.from(prev[activeGW] || []).filter((id) => String(id) !== String(currentId)) }));
1277
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null);
1278
+ };
1279
+
1280
+ const resetHighlightedTransfer = (player) => {
1281
+ const pair = (solverTransferPairs[activeGW] || {})[player.ID];
1282
+ if (pair?.outPlayer) {
1283
+ const idx = teamData.findIndex((p) => p.ID === player.ID);
1284
+ if (idx < 0) return;
1285
+
1286
+ // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup
1287
+ // const freshOutPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(pair.outPlayer.ID)) || pair.outPlayer), Price: getPlayerPrice(pair.outPlayer) };
1288
+ const freshOutPlayer = hydratePlayer(pair.outPlayer.ID, pair.outPlayer) || pair.outPlayer;
1289
+ const newSquad = [...teamData]; newSquad[idx] = freshOutPlayer;
1290
+
1291
+ const buyPrice = getPlayerPrice(player); const sellPrice = getPlayerPrice(pair.outPlayer);
1292
+
1293
+ let nextTransfers = { ...transfersByGw };
1294
+ const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 };
1295
+ nextTransfers[activeGW] = {
1296
+ ...row, count: Math.max(0, row.count - 1), netDelta: row.netDelta - (sellPrice - buyPrice),
1297
+ inIds: Array.from(row.inIds || []).filter(id => String(id) !== String(player.ID)), outIds: Array.from(row.outIds || []).filter(id => String(id) !== String(pair.outPlayer.ID))
1298
+ };
1299
+
1300
+ const opt = getValidLayout(newSquad, activeGW);
1301
+ const finalSquad = opt ? opt.optimalArray : newSquad;
1302
+
1303
+ let nextOverrides = { ...manualOverrides };
1304
+ nextOverrides[activeGW] = { ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: nextOverrides[activeGW]?.forcedZeros || [] };
1305
+
1306
+ const mapping = { [player.ID]: pair.outPlayer.ID };
1307
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
1308
+
1309
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad);
1310
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
1311
+
1312
+ const nP = { ...cascadedP };
1313
+ if (nP[activeGW]) { delete nP[activeGW][player.ID]; }
1314
+ setSolverTransferPairs(nP);
1315
+
1316
+ setHighlightTransferIds((prev) => { const n = { ...prev }; if (n[activeGW]) { const gwSet = new Set(n[activeGW]); gwSet.delete(player.ID); n[activeGW] = Array.from(gwSet); } return clearFuture(n); });
1317
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); return;
1318
+ }
1319
+ if (player.replacedPlayer) { handleUndoTransfer({ stopPropagation: () => { } }, player.ID, player.replacedPlayer); return; }
1320
+ handleTransferOut(player);
1321
+ };
1322
+
1323
+ const handleResetGWTransfers = () => {
1324
+ let previousSquadIds = [];
1325
+ const currentIndex = availableGWs.indexOf(activeGW);
1326
+ if (currentIndex > 0) {
1327
+ const prevGw = availableGWs[currentIndex - 1];
1328
+ previousSquadIds = manualOverrides[prevGw]?.ids || initialSquadIds;
1329
+ } else {
1330
+ previousSquadIds = initialSquadIds;
1331
+ }
1332
+
1333
+ //const restoredSquadUnsorted = previousSquadIds.map(id => {
1334
+ // const p = globalPlayers.find(x => String(x.ID) === String(id));
1335
+ // const existing = teamData.find(t => String(t.ID) === String(id));
1336
+ // return existing ? { ...p, Price: existing.Price } : { ...p, Price: getPlayerPrice(p) };
1337
+ // }).filter(Boolean);
1338
+ const restoredSquadUnsorted = previousSquadIds.map(id => hydratePlayer(id)).filter(Boolean);
1339
+
1340
+ const opt = getValidLayout(restoredSquadUnsorted, activeGW);
1341
+ const finalSquad = opt ? opt.optimalArray : restoredSquadUnsorted;
1342
+
1343
+ let nextTransfers = { ...transfersByGw };
1344
+ delete nextTransfers[activeGW];
1345
+
1346
+ let nextOverrides = { ...manualOverrides };
1347
+ nextOverrides[activeGW] = {
1348
+ ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: [], manualTransfers: {}
1349
+ };
1350
+
1351
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs);
1352
+
1353
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad);
1354
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
1355
+
1356
+ const nP = { ...cascadedP };
1357
+ delete nP[activeGW];
1358
+ setSolverTransferPairs(nP);
1359
+
1360
+ setHighlightTransferIds(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); });
1361
+ setChipsByGw(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); });
1362
+ setSolverApplySnapshot(null); setAppliedPlanSummary(null);
1363
+ };
1364
+
1365
+ // --- UI FIREWALL ---
1366
+ // Forces the Pitch to instantly drop stale undo buttons during GW tab switches
1367
+ const renderTeamData = useMemo(() => {
1368
+ const lock = manualOverrides[activeGW];
1369
+ return teamData.map(p => {
1370
+ if (p.isBlank && String(p.ID).startsWith("blank_")) return p;
1371
+ const cleanP = { ...p };
1372
+ if (lock?.manualTransfers && lock.manualTransfers[p.ID]) {
1373
+ cleanP.replacedPlayer = lock.manualTransfers[p.ID];
1374
+ } else {
1375
+ delete cleanP.replacedPlayer;
1376
+ }
1377
+ return cleanP;
1378
+ });
1379
+ }, [teamData, activeGW, manualOverrides]);
1380
+
1381
+ return (
1382
+ <div className="flex flex-col w-full h-full pb-10">
1383
+
1384
+ {/* Minimal Top Bar for Load */}
1385
+ <div className="w-full flex justify-end mb-4 z-40">
1386
+ <form onSubmit={fetchTeam} className="flex gap-2 items-center bg-slate-900/40 px-4 py-2 rounded-xl border border-slate-800 backdrop-blur-sm shadow-xl">
1387
+ <div className="relative w-48">
1388
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={14} />
1389
+ <input type="text" placeholder="FPL Team ID..." value={teamId} onChange={(e) => { setTeamId(e.target.value); setLastLoadedId(null); }} className="w-full bg-slate-950 border border-slate-700 rounded-lg py-1.5 pl-8 pr-3 text-xs text-slate-200 focus:outline-none focus:border-luigi-400 shadow-inner" />
1390
+ </div>
1391
+ <button type="submit" disabled={isLoading || (teamData.length > 0 && teamId === lastLoadedId)} className="bg-luigi-500 hover:bg-luigi-400 text-slate-950 font-bold px-3 py-1.5 rounded-lg text-xs flex items-center gap-1.5 shadow-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
1392
+ {isLoading ? <Loader2 size={14} className="animate-spin" /> : "Load"}
1393
+ </button>
1394
+ </form>
1395
+ </div>
1396
+
1397
+ <div className="flex flex-col xl:flex-row gap-8 w-full">
1398
+ <div className="w-full xl:w-[72%] flex flex-col gap-4 xl:-mt-12 relative z-10">
1399
+ {teamData.length > 0 ? (
1400
+ <>
1401
+
1402
+ {/* Pitch Rendering wrapper */}
1403
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
1404
+
1405
+ <div className="flex flex-col sm:flex-row items-center justify-between gap-x-6 gap-y-3 bg-slate-900/30 p-4 rounded-xl border border-slate-800/50 mb-2 min-h-[72px] sm:min-h-0">
1406
+
1407
+ {/* STATS ROW - Clean spacing, non-wrapping to prevent jitter */}
1408
+ <div className="flex items-center gap-4 sm:gap-6 w-full sm:w-auto shrink-0">
1409
+ <div className="flex flex-col">
1410
+ <span className="text-[10px] font-black text-slate-500 uppercase">ITB</span>
1411
+ <span className="text-sm font-mono font-bold text-emerald-400 tabular-nums">£{Math.abs(itb) < 0.05 ? "0.0" : itb.toFixed(1)}m</span>
1412
+ </div>
1413
+ <div className="flex flex-col">
1414
+ <span className="text-[10px] font-black text-slate-500 uppercase">FT</span>
1415
+ <span className="text-sm font-mono font-bold text-cyan-400 leading-tight">
1416
+ {(() => {
1417
+ const chip = chipsByGw[activeGW]; const T = transfersByGw[activeGW]?.count ?? 0;
1418
+ if (chip === "wc") return <><span className="text-yellow-400 text-xs font-black">⚡ WC</span> <span className="text-slate-500 text-[10px]">({T}/∞)</span></>;
1419
+ if (chip === "fh") return <><span className="text-orange-400 text-xs font-black">↩ FH</span> <span className="text-slate-500 text-[10px]">({T}/∞)</span></>;
1420
+ return `${T} / ${ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw)}${hitsThisGw > 0 ? ` (-${hitsThisGw * 4} pts)` : ""}`;
1421
+ })()}
1422
+ </span>
1423
+ </div>
1424
+ <div className="flex flex-col">
1425
+ <span className="text-[10px] font-black text-slate-500 uppercase">Horizon</span>
1426
+ <select value={horizon} onChange={(e) => setHorizon(Number(e.target.value))} className="bg-transparent text-sm font-mono font-bold text-luigi-400 outline-none cursor-pointer">
1427
+ {Array.from({ length: maxAvailableHorizon }, (_, i) => i + 1).map((h) => (<option key={h} value={h} className="bg-slate-900">{h} {h === 1 ? "GW" : "GWs"}</option>))}
1428
+ </select>
1429
+ </div>
1430
+ <div className="h-8 w-px bg-slate-700 hidden sm:block"></div>
1431
+ <div className="flex flex-col">
1432
+ <span className="text-[10px] font-black text-slate-500 uppercase whitespace-nowrap">GW {activeGW} EV</span>
1433
+ <span className="text-sm font-mono font-bold text-cyan-400 tabular-nums">{activeGwEV.toFixed(2)}</span>
1434
+ </div>
1435
+ {horizonGWs.length > 1 && (
1436
+ <div className="flex flex-col">
1437
+ <span className="text-[10px] font-black text-slate-500 uppercase whitespace-nowrap">Horizon EV</span>
1438
+ <span className="text-sm font-mono font-bold text-emerald-400 tabular-nums">{horizonEV.toFixed(2)}</span>
1439
+ </div>
1440
+ )}
1441
+ </div>
1442
+
1443
+ {/* BUTTON ROW - Pushed to the right, strictly locked nowrap */}
1444
+ <div className="flex flex-nowrap items-center justify-end gap-2 sm:gap-3 w-full sm:w-auto min-h-[32px] shrink-0">
1445
+
1446
+ {/* 1. RESET TRANSFERS/CHIPS BUTTON (Always Rendered, Disabled if Not Needed) */}
1447
+ {(() => {
1448
+ const canReset = (transfersByGw[activeGW]?.count > 0) || chipsByGw[activeGW] || (manualOverrides[activeGW]?.manualTransfers && Object.keys(manualOverrides[activeGW].manualTransfers).length > 0);
1449
+ return (
1450
+ <button
1451
+ type="button"
1452
+ onClick={handleResetGWTransfers}
1453
+ disabled={!canReset}
1454
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-black uppercase tracking-wider transition-all shadow-lg shrink-0 whitespace-nowrap hover:bg-red-500/20 hover:border-red-500/40 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-500/10 disabled:hover:border-red-500/20 disabled:active:scale-100 disabled:shadow-none"
1455
+ >
1456
+ <RotateCcw size={12} /> Reset
1457
+ </button>
1458
+ );
1459
+ })()}
1460
+
1461
+ {/* 2. RESET LINEUP BUTTON (Always Rendered, Disabled if Not Needed) */}
1462
+ {(() => {
1463
+ let canResetLineup = false;
1464
+ const gwLock = manualOverrides[activeGW];
1465
+
1466
+ if (gwLock?.ids && teamData.length === 15 && !teamData.some((p) => p.isBlank && !String(p.ID).startsWith("blank_"))) {
1467
+ const opt = getValidLayout(teamData, activeGW);
1468
+ if (opt) {
1469
+ const lockStarterSet = new Set(gwLock.ids.slice(0, 11));
1470
+ const optStarterSet = new Set(opt.optimalArray.slice(0, 11).map((p) => p.ID));
1471
+ const differentStarters = lockStarterSet.size !== optStarterSet.size || [...lockStarterSet].some((id) => !optStarterSet.has(id));
1472
+ const meaningfulDiff = differentStarters || gwLock.cap !== opt.cap || gwLock.vice !== opt.vice;
1473
+
1474
+ if (meaningfulDiff) {
1475
+ const getPts = (p) => Number(p[`${activeGW}_Pts`]) || 0;
1476
+ const currentEV = teamData.slice(0, 11).reduce((sum, p) => sum + getPts(p) * (p.ID === gwLock.cap ? 2 : 1), 0);
1477
+ const optEV = opt.optimalArray.slice(0, 11).reduce((sum, p) => sum + getPts(p) * (p.ID === opt.cap ? 2 : 1), 0);
1478
+
1479
+ if (optEV > currentEV + 0.01) {
1480
+ canResetLineup = true;
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+
1486
+ return (
1487
+ <button
1488
+ onClick={handleResetGW}
1489
+ disabled={!canResetLineup}
1490
+ className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-luigi-500/10 border border-luigi-500/20 text-luigi-400 text-[10px] font-black uppercase tracking-wider transition-all shadow-lg shrink-0 whitespace-nowrap hover:bg-luigi-500/20 hover:border-luigi-500/40 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-luigi-500/10 disabled:hover:border-luigi-500/20 disabled:active:scale-100 disabled:shadow-none"
1491
+ title="Reset Lineup to Optimal"
1492
+ >
1493
+ <RotateCcw size={12} /> Reset Lineup
1494
+ </button>
1495
+ );
1496
+ })()}
1497
+
1498
+ {/* 3. CHIP DROPDOWN */}
1499
+ <div className="flex items-center gap-1.5 shrink-0">
1500
+ <span className="text-[10px] font-black text-slate-500 uppercase tracking-wider">Chip:</span>
1501
+ <select value={chipsByGw[activeGW] || ""} onChange={(e) => handleChipSelect(activeGW, e.target.value || null)} className="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs font-bold text-slate-300 focus:outline-none cursor-pointer focus:border-luigi-500">
1502
+ <option value="">None</option><option value="wc">⚡ WC</option><option value="fh">↩ FH</option><option value="bb">⬆ BB</option><option value="tc">✕3 TC</option>
1503
+ </select>
1504
+ </div>
1505
+
1506
+ </div>
1507
+ </div>
1508
+
1509
+ <DraftsComparisonTable
1510
+ drafts={drafts}
1511
+ horizonGWs={horizonGWs}
1512
+ activeDraftId={activeDraftId}
1513
+ globalPlayers={globalPlayers}
1514
+ setActiveDraftId={setActiveDraftId}
1515
+ getValidLayout={getValidLayout}
1516
+ availableGWs={availableGWs}
1517
+ setDrafts={setDrafts}
1518
+ baselineFt={baselineFt}
1519
+ baselineItb={baselineItb}
1520
+ ftAtStartOfGw={ftAtStartOfGw}
1521
+ advancedSettings={advancedSettings}
1522
+ />
1523
+
1524
+ {/* MULTIVERSE TIMELINE CONTROL BAR */}
1525
+ <div className="flex flex-col md:flex-row items-center justify-between gap-3 bg-slate-900/80 p-2.5 rounded-xl border border-[#2a2d5c] backdrop-blur-md shadow-lg mb-4 relative z-20">
1526
+
1527
+ {/* LEFT: Custom Editable Dropdown Box */}
1528
+ <div className="relative w-full md:w-56 shrink-0 z-50">
1529
+ <div className="flex items-center bg-[#0a0f1c] border border-[#2a2d5c] rounded-lg overflow-hidden shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] focus-within:border-indigo-500 transition-colors h-8">
1530
+ <input
1531
+ type="text"
1532
+ value={drafts.find(d => d.id === activeDraftId)?.name || ""}
1533
+ onChange={(e) => setDrafts(prev => prev.map(d => d.id === activeDraftId ? { ...d, name: e.target.value } : d))}
1534
+ className="w-full bg-transparent text-indigo-100 font-bold text-[11px] py-1 px-3 outline-none placeholder:text-slate-600"
1535
+ placeholder="Draft Name..."
1536
+ />
1537
+ <button
1538
+ onClick={() => setShowDraftMenu(!showDraftMenu)}
1539
+ className="px-2.5 h-full flex items-center justify-center bg-[#151833] border-l border-[#2a2d5c] hover:bg-[#1e2247] transition-colors"
1540
+ >
1541
+ <span className="text-indigo-400 text-[8px]">▼</span>
1542
+ </button>
1543
+ </div>
1544
+
1545
+ {showDraftMenu && (
1546
+ <>
1547
+ <div className="fixed inset-0 z-40" onClick={() => setShowDraftMenu(false)} />
1548
+ <div className="absolute top-full left-0 mt-1.5 w-full bg-[#0a0f1c] border border-[#2a2d5c] rounded-lg shadow-[0_10px_40px_rgba(0,0,0,0.8)] overflow-hidden py-1 z-50">
1549
+ {drafts.map(d => (
1550
+ <button
1551
+ key={d.id}
1552
+ onClick={() => { setActiveDraftId(d.id); setShowDraftMenu(false); }}
1553
+ className={`w-full text-left px-3 py-2 text-[11px] font-bold transition-colors ${d.id === activeDraftId ? "bg-indigo-500/20 text-indigo-300" : "text-slate-400 hover:bg-[#151833] hover:text-slate-200"}`}
1554
+ >
1555
+ {d.name}
1556
+ </button>
1557
+ ))}
1558
+ </div>
1559
+ </>
1560
+ )}
1561
+ </div>
1562
+
1563
+ {/* CENTER: Gameweek Circles */}
1564
+ <div className="flex gap-1.5 flex-wrap justify-center flex-1">
1565
+ {horizonGWs.map((gw) => (
1566
+ <button key={gw} type="button" onClick={() => setActiveGW(gw)} className={`relative w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold transition-all ${activeGW === gw ? "bg-luigi-500 text-slate-950 scale-110 shadow-[0_0_10px_rgba(16,185,129,0.5)]" : "bg-slate-800 text-slate-400 hover:bg-slate-700 border border-slate-700"}`}>
1567
+ {gw}
1568
+ {chipsByGw[gw] && (<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${CHIP_CONFIG[chipsByGw[gw]].dot} flex items-center justify-center text-[6px] font-black text-slate-950 border border-slate-950 leading-none`} title={CHIP_CONFIG[chipsByGw[gw]].label}>{CHIP_CONFIG[chipsByGw[gw]].short[0]}</span>)}
1569
+ </button>
1570
+ ))}
1571
+ </div>
1572
+
1573
+ {/* RIGHT: Clone / New Draft / Delete */}
1574
+ {/* RIGHT: Clone / New Draft */}
1575
+ <div className="flex items-center justify-end gap-1.5 w-full md:w-auto shrink-0">
1576
+ <button onClick={handleCloneDraft} disabled={drafts.length >= 5} className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed" title="Clone reality">
1577
+ <Copy size={12} /> Clone
1578
+ </button>
1579
+ <button onClick={handleNewDraft} disabled={drafts.length >= 5} className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed" title="New timeline">
1580
+ <Plus size={12} /> New
1581
+ </button>
1582
+ </div>
1583
+ </div>
1584
+
1585
+ <PitchView
1586
+ teamData={renderTeamData}
1587
+ activeDragPlayer={activeDragPlayer}
1588
+ isValidSwap={isValidSwap}
1589
+ captainId={captainId}
1590
+ viceId={viceId}
1591
+ handleCapChange={handleCapChange}
1592
+ playerCardGWs={playerCardGWs}
1593
+ fixtures={fixtures}
1594
+ activeGW={activeGW}
1595
+ setSelectedPlayer={setSelectedPlayer}
1596
+ handleUndoTransfer={handleUndoTransfer}
1597
+ highlightTransferIds={highlightTransferIds}
1598
+ solverTransferPairs={solverTransferPairs}
1599
+ resetHighlightedTransfer={resetHighlightedTransfer}
1600
+ chipsByGw={chipsByGw}
1601
+ />
1602
+
1603
+ <DragOverlay dropAnimation={null}>
1604
+ {activeDragPlayer && !activeDragPlayer.isBlank ? (
1605
+ <PlayerCardVisual player={activeDragPlayer} isBench={false} captainId={captainId} viceId={viceId} playerCardGWs={playerCardGWs} fixtures={fixtures} activeGW={activeGW} />
1606
+ ) : null}
1607
+ </DragOverlay>
1608
+ </DndContext>
1609
+ </>
1610
+ ) : (
1611
+ <div className="w-full min-h-[500px] border-2 border-dashed border-slate-800 rounded-2xl flex items-center justify-center text-slate-500 bg-[#0a3a2a]/30 flex-col gap-4 relative overflow-hidden">
1612
+ {isLoadingDB || isLoading ? (
1613
+ <>
1614
+ <div className="absolute inset-0 pointer-events-none flex flex-col items-center justify-evenly py-12 px-8">
1615
+ {[1, 4, 4, 2].map((count, rowIdx) => (
1616
+ <div key={rowIdx} className="flex justify-center gap-6 sm:gap-10">
1617
+ {Array.from({ length: count }).map((_, i) => (
1618
+ <div key={i} className="w-[52px] sm:w-[68px] h-[72px] sm:h-[92px] rounded-xl bg-slate-800/50 skeleton-pulse" style={{ animationDelay: `${(rowIdx * count + i) * 0.12}s` }} />
1619
+ ))}
1620
+ </div>
1621
+ ))}
1622
+ </div>
1623
+ <Loader2 size={32} className="animate-spin text-emerald-500 z-10" />
1624
+ <span className="z-10 text-sm font-bold text-slate-400">{isLoading ? "Loading squad..." : "Booting Global Engine..."}</span>
1625
+ </>
1626
+ ) : (
1627
+ "Enter your FPL ID above to load your squad."
1628
+ )}
1629
+ </div>
1630
+ )}
1631
+ </div>
1632
+
1633
+ {/* Right Column */}
1634
+ <div className="w-full xl:w-[28%] flex flex-col gap-4">
1635
+
1636
+ {/* NEW HOME FOR TABS PANEL */}
1637
+ <div className="rounded-2xl border border-slate-700/50 bg-slate-950/80 backdrop-blur-md shadow-xl overflow-hidden relative shrink-0">
1638
+ <TabsPanel
1639
+ solverTab={solverTab}
1640
+ setSolverTab={setSolverTab}
1641
+ isSolving={isSolving}
1642
+ isRunningSens={isRunningSens}
1643
+ isChipSolving={isChipSolving}
1644
+ runMainSolver={runMainSolver}
1645
+ runSensAnalysis={runSensAnalysis}
1646
+ runChipSolve={runChipSolve}
1647
+ setShowAdvancedSettings={setShowAdvancedSettings}
1648
+ quickSettings={quickSettings}
1649
+ setQuickSettings={setQuickSettings}
1650
+ banSearch={banSearch}
1651
+ setBanSearch={setBanSearch}
1652
+ lockSearch={lockSearch}
1653
+ setLockSearch={setLockSearch}
1654
+ globalPlayers={globalPlayers}
1655
+ teamData={renderTeamData}
1656
+ solveGWLabel={solveGWLabel}
1657
+ numSims={numSims}
1658
+ setNumSims={setNumSims}
1659
+ sensResults={sensResults}
1660
+ setSensResults={setSensResults}
1661
+ sensViewGw={sensViewGw}
1662
+ setSensViewGw={setSensViewGw}
1663
+ chipSolveOptions={chipSolveOptions}
1664
+ setChipSolveOptions={setChipSolveOptions}
1665
+ chipSolveSolutions={chipSolveSolutions}
1666
+ setChipSolveSolutions={setChipSolveSolutions}
1667
+ horizonGWs={horizonGWs}
1668
+ baselineEv={horizonEV}
1669
+ />
1670
+ </div>
1671
+
1672
+ <ActiveMovesPanel
1673
+ activeGW={activeGW}
1674
+ manualOverrides={manualOverrides}
1675
+ globalPlayers={globalPlayers}
1676
+ chipsByGw={chipsByGw}
1677
+ transfersByGw={transfersByGw}
1678
+ />
1679
+
1680
+ <SolverOutputPanel
1681
+ pendingSolutions={pendingSolutions}
1682
+ setPendingSolutions={setPendingSolutions}
1683
+ isSolving={isSolving}
1684
+ globalPlayers={globalPlayers}
1685
+ applySolution={applySolution}
1686
+ appliedPlanSummary={appliedPlanSummary}
1687
+ setAppliedPlanSummary={setAppliedPlanSummary}
1688
+ baselineEv={horizonEV}
1689
+ />
1690
+ </div>
1691
+
1692
+ </div>
1693
+
1694
+ {/* MODALS */}
1695
+ {selectedPlayer && !selectedPlayer.isBlank && (
1696
+ <PlayerEditModal
1697
+ selectedPlayer={selectedPlayer} setSelectedPlayer={setSelectedPlayer} activeGW={activeGW} horizonGWs={horizonGWs} updatePlayerStat={updatePlayerStat} handleTransferOut={handleTransferOut} fixtures={fixtures} fixtureOverrides={fixtureOverrides} sessionEdits={sessionEdits} globalPlayers={globalPlayers}
1698
+ />
1699
+ )}
1700
+ {selectedPlayer && selectedPlayer.isBlank && (
1701
+ <PlayerSearchModal
1702
+ selectedPlayer={selectedPlayer} setSelectedPlayer={setSelectedPlayer} searchQuery={searchQuery} setSearchQuery={setSearchQuery} sortConfig={sortConfig} setSortConfig={setSortConfig} globalPlayers={globalPlayers} ownedPlayerIds={ownedPlayerIds} activeGW={activeGW} itb={itb} handleAddPlayer={handleAddPlayer}
1703
+ />
1704
+ )}
1705
+ {showAdvancedSettings && (
1706
+ <AdvancedSettingsModal
1707
+ setShowAdvancedSettings={setShowAdvancedSettings} comprehensiveSettings={comprehensiveSettings} setComprehensiveSettings={setComprehensiveSettings} advancedSettings={advancedSettings} setAdvancedSettings={setAdvancedSettings}
1708
+ />
1709
+ )}
1710
+
1711
+ {/* LOADING PORTALS */}
1712
+ {isSolving && createPortal(
1713
+ <div className="fixed inset-0 z-[500] flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6">
1714
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(16,185,129,0.14),transparent_50%)]" />
1715
+ <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-luigi-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(16,185,129,0.15)]">
1716
+ <div className="relative flex h-32 w-32 items-center justify-center">
1717
+ <div className="absolute inset-0 rounded-full border-4 border-slate-800" />
1718
+ <div className="absolute inset-0 animate-spin rounded-full border-4 border-luigi-500 border-t-transparent" />
1719
+ {/* BRANDED LOGO */}
1720
+ <img src="/l-logo.png" alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(16,185,129,0.6)]" />
1721
+ </div>
1722
+ <div className="text-center">
1723
+ <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-luigi-400">Solving</p>
1724
+ <p className="font-mono text-xs text-slate-400">Elapsed {solveElapsedSec}s · up to {quickSettings.iterations} iteration(s)</p>
1725
+ </div>
1726
+ <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button>
1727
+ </div>
1728
+ </div>, document.body
1729
+ )}
1730
+
1731
+ {isChipSolving && createPortal(
1732
+ <div className="fixed inset-0 z-[500] flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6">
1733
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,0.14),transparent_50%)]" />
1734
+ <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-purple-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(168,85,247,0.15)]">
1735
+ <div className="relative flex h-32 w-32 items-center justify-center">
1736
+ <div className="absolute inset-0 rounded-full border-4 border-slate-800" />
1737
+ <div className="absolute inset-0 animate-spin rounded-full border-4 border-purple-500 border-t-transparent" />
1738
+ {/* BRANDED LOGO */}
1739
+ <img src={lLogo} alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(168,85,247,0.6)]" />
1740
+ </div>
1741
+ <div className="text-center">
1742
+ <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-purple-400">Chip Solving</p>
1743
+ <p className="font-mono text-xs text-slate-400">Elapsed {chipSolveTimer}s</p>
1744
+
1745
+ </div>
1746
+ <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button>
1747
+ </div>
1748
+ </div>, document.body
1749
+ )}
1750
+
1751
+ {isRunningSens && createPortal(
1752
+ <div className="fixed inset-0 z-[500] flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6">
1753
+ <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(6,182,212,0.14),transparent_50%)]" />
1754
+ <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-cyan-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(6,182,212,0.15)]">
1755
+ <div className="relative flex h-32 w-32 items-center justify-center">
1756
+ <div className="absolute inset-0 rounded-full border-4 border-slate-800" />
1757
+ <div className="absolute inset-0 animate-spin rounded-full border-4 border-cyan-500 border-t-transparent" />
1758
+ {/* BRANDED LOGO */}
1759
+ <img src={lLogo} alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(6,182,212,0.6)]" />
1760
+ </div>
1761
+ <div className="text-center">
1762
+ <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-cyan-400">Sensitivity Analysis</p>
1763
+ <p className="font-mono text-xs text-slate-400">Elapsed {sensTimer}s · {numSims} sims running…</p>
1764
+
1765
+ </div>
1766
+ <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button>
1767
+ </div>
1768
+ </div>, document.body
1769
+ )}
1770
+
1771
+ {showIdPrompt && (
1772
+ <div className="fixed inset-0 z-[200] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
1773
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-sm rounded-2xl p-6 flex flex-col items-center">
1774
+ <Shield size={24} className="text-luigi-400 mb-4" />
1775
+ <h3 className="text-xl font-black text-slate-100 mb-2">Save as Default ID?</h3>
1776
+ <div className="flex gap-3 w-full mt-4">
1777
+ <button onClick={() => setShowIdPrompt(false)} className="flex-1 bg-slate-900 text-slate-300 py-2.5 rounded-xl border border-slate-700">Not Now</button>
1778
+ <button onClick={() => { setUserProfile((prev) => ({ ...prev, defaultTeamId: pendingTeamId })); setShowIdPrompt(false); }} className="flex-1 bg-luigi-500 text-slate-950 py-2.5 rounded-xl font-bold">Save ID</button>
1779
+ </div>
1780
+ </div>
1781
+ </div>
1782
+ )}
1783
+ {/* INITIAL LOGIN ID PROMPT */}
1784
+ {showInitialIdPrompt && (
1785
+ <div className="fixed inset-0 z-[600] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
1786
+ <div className="bg-slate-950 border border-slate-800 w-full max-w-sm rounded-2xl p-6 flex flex-col items-center shadow-[0_0_40px_rgba(16,185,129,0.1)]">
1787
+ <Shield size={32} className="text-emerald-500 mb-4" />
1788
+ <h3 className="text-xl font-black text-slate-100 mb-2">Welcome!</h3>
1789
+ <p className="text-xs text-slate-400 text-center mb-6">Enter your FPL Team ID to set it as your default for future logins.</p>
1790
+ <input
1791
+ type="number"
1792
+ value={initialIdInput}
1793
+ onChange={(e) => setInitialIdInput(e.target.value)}
1794
+ placeholder="e.g. 123456"
1795
+ className="w-full bg-slate-900 border border-slate-700 rounded-lg py-2.5 px-4 text-sm font-bold text-slate-200 focus:outline-none focus:border-emerald-500 text-center mb-4"
1796
+ />
1797
+ <div className="flex gap-3 w-full">
1798
+ <button onClick={() => setShowInitialIdPrompt(false)} className="flex-1 bg-slate-900 text-slate-400 py-2.5 rounded-xl border border-slate-700 hover:text-slate-300 transition-colors text-sm font-bold">Skip</button>
1799
+ <button onClick={handleSaveInitialId} className="flex-1 bg-emerald-500 text-slate-950 py-2.5 rounded-xl font-black hover:bg-emerald-400 transition-colors shadow-lg text-sm">Save Default ID</button>
1800
+ </div>
1801
+ </div>
1802
+ </div>
1803
+ )}
1804
+ </div>
1805
+ );
1806
+ }
frontend/src/components/SolverOutputPanel.jsx ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { Zap, ExternalLink } from "lucide-react";
3
+ import { CHIP_CONFIG } from "../utils/fplLogic";
4
+
5
+ export const SolverOutputPanel = ({
6
+ pendingSolutions, setPendingSolutions, isSolving, globalPlayers, applySolution, appliedPlanSummary, setAppliedPlanSummary, baselineEv = 0
7
+ }) => {
8
+
9
+ // BULLETPROOF RELATIVE EV
10
+ const getRelativeEv = (sol) => {
11
+ if (baselineEv === undefined || !sol) return "+0.00";
12
+
13
+ const base = sol.lockedBaselineEv !== undefined ? sol.lockedBaselineEv : baselineEv;
14
+
15
+ if (typeof sol === "number") {
16
+ const diff = sol - base;
17
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
18
+ }
19
+
20
+ if (!sol.plan || !Array.isArray(sol.plan) || sol.plan.length === 0) {
21
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
22
+ const diff = fallbackEv - base;
23
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
24
+ }
25
+
26
+ let pathEV = 0;
27
+ let hasValidGw = false;
28
+
29
+ sol.plan.forEach(gwPlan => {
30
+ const gw = gwPlan.gw;
31
+ if (gw === undefined) return;
32
+ hasValidGw = true;
33
+
34
+ const gwChip = gwPlan.chip;
35
+ const gwCapMult = gwChip === "tc" ? 3 : 2;
36
+ let gwPts = 0;
37
+
38
+ const getPlayer = (id) => globalPlayers.find(p => String(p.ID) === String(id));
39
+
40
+ (gwPlan.lineup || []).forEach(id => {
41
+ const p = getPlayer(id);
42
+ if (p && !p.isBlank) {
43
+ const pts = Number(p[`${gw}_Pts`]) || 0;
44
+ gwPts += pts * (String(p.ID) === String(gwPlan.captain) ? gwCapMult : 1);
45
+ }
46
+ });
47
+
48
+ let ofIdx = 0;
49
+ (gwPlan.bench || []).forEach(id => {
50
+ const p = getPlayer(id);
51
+ if (p && !p.isBlank) {
52
+ const pts = Number(p[`${gw}_Pts`]) || 0;
53
+ if (gwChip === "bb") {
54
+ gwPts += pts;
55
+ } else if (p.Pos === "G") {
56
+ gwPts += pts * 0.04;
57
+ } else {
58
+ gwPts += pts * ([0.17, 0.05, 0.02][ofIdx] || 0.02);
59
+ ofIdx++;
60
+ }
61
+ }
62
+ });
63
+ pathEV += gwPts - (gwPlan.hits || 0) * 4;
64
+ });
65
+
66
+ if (!hasValidGw || Number.isNaN(pathEV)) {
67
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
68
+ const diff = fallbackEv - base;
69
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
70
+ }
71
+
72
+ const diff = pathEV - base;
73
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
74
+ };
75
+
76
+ return (
77
+ <div className="w-full bg-slate-950 border border-slate-800 rounded-2xl flex flex-col h-auto shadow-2xl overflow-hidden relative min-h-[320px]">
78
+ <div className="border-b border-slate-800 px-5 py-4 bg-slate-900/50 flex items-center justify-between">
79
+
80
+ {/* Left Side: Original Title & Description */}
81
+ <div className="flex flex-col">
82
+ <h2 className="text-sm font-black uppercase tracking-widest text-slate-300">Solver output</h2>
83
+ <p className="text-[10px] text-slate-500 mt-1">Nothing changes your squad until you apply a path.</p>
84
+ </div>
85
+
86
+ {/* Right Side: Sleek, Minimalist Credit */}
87
+ {/* Right Side: Sleek, Minimalist Credit */}
88
+ <a
89
+ href="https://github.com/sertalpbilal/FPL-Optimization-Tools"
90
+ target="_blank"
91
+ rel="noopener noreferrer"
92
+ className="group flex items-center gap-1 text-[9px] font-bold uppercase tracking-widest transition-colors text-right whitespace-nowrap ml-4 shrink-0"
93
+ >
94
+ <span className="text-slate-600">Credit</span>
95
+ <span className="text-slate-500 group-hover:text-luigi-400 transition-colors">Sertalp-Moose Solver</span>
96
+ <ExternalLink size={10} className="text-slate-600 group-hover:text-luigi-400 transition-colors" />
97
+ </a>
98
+
99
+ </div>
100
+
101
+ <div className="flex-1 flex flex-col p-5 overflow-y-auto custom-scrollbar">
102
+ {pendingSolutions.length > 0 && !isSolving && (
103
+ <div className="flex flex-col gap-4">
104
+ <div className="flex items-center justify-between mb-2">
105
+ <h3 className="text-slate-200 font-black">Optimal Paths Found</h3>
106
+ <button onClick={() => setPendingSolutions([])} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase transition-colors">Clear</button>
107
+ </div>
108
+
109
+ {pendingSolutions.map((sol, index) => (
110
+ <div key={index} className="bg-slate-900 border border-luigi-500/30 rounded-xl p-4 flex flex-col gap-4">
111
+ <div className="flex justify-between items-center border-b border-slate-800 pb-2">
112
+ <div className="flex items-center gap-2">
113
+ <span className="font-black text-slate-300">ITERATION {sol.id || index + 1}</span>
114
+ {sol.chips_used && Object.entries(sol.chips_used).map(([gw, chip]) => {
115
+ const cfg = CHIP_CONFIG[chip];
116
+ return cfg ? <span key={gw} className={`text-[9px] font-black px-1.5 py-0.5 rounded ${cfg.badge}`} title={`${cfg.label} in GW${gw}`}>{cfg.short}{gw}</span> : null;
117
+ })}
118
+ </div>
119
+ <div className="flex flex-col items-end gap-0.5">
120
+ <span className="text-luigi-400 font-mono font-bold text-sm">{getRelativeEv(sol)} pts</span>
121
+ {sol.objective_score != null && <span className="text-slate-400 font-mono text-[10px]">eval: {sol.objective_score.toFixed(2)}</span>}
122
+ </div>
123
+ </div>
124
+
125
+ <div className="flex flex-col gap-2">
126
+ {sol.plan.map((gwPlan) => (
127
+ (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) && (
128
+ <div key={gwPlan.gw} className="bg-slate-950 rounded p-2 text-xs">
129
+ <div className="text-slate-500 font-bold mb-2 flex justify-between items-center">
130
+ <div className="flex items-center gap-2">
131
+ <span className="bg-slate-800 px-2 py-1 rounded text-slate-300">GW {gwPlan.gw}</span>
132
+ {gwPlan.chip && CHIP_CONFIG[gwPlan.chip] && <span className={`text-[9px] font-black px-1.5 py-0.5 rounded ${CHIP_CONFIG[gwPlan.chip].badge}`}>{CHIP_CONFIG[gwPlan.chip].short}{gwPlan.gw}</span>}
133
+ </div>
134
+ <div className="flex gap-3">
135
+ <span className="text-emerald-400 font-mono">ITB: £{Math.abs(gwPlan.itb) < 0.05 ? "0.0" : Number(gwPlan.itb).toFixed(1)}</span>
136
+ <span className="text-cyan-400 font-mono">FT Spend: {gwPlan.chip === "wc" || gwPlan.chip === "fh" ? `${gwPlan.transfers_out?.length || 0}/∞` : `${gwPlan.transfers_out?.length || 0}/${gwPlan.ft_at_start ?? 1}${gwPlan.hits > 0 ? ` (-${gwPlan.hits * 4})` : ""}`}</span>
137
+ </div>
138
+ </div>
139
+ {gwPlan.chip === "wc" || gwPlan.chip === "fh" ? <p className="text-[10px] text-slate-500 italic mb-1">{gwPlan.chip === "wc" ? "Wildcard active — unlimited free transfers" : "Free Hit active — squad reverts after the FH"}</p> : null}
140
+ {gwPlan.transfers_out.map((id, i) => (
141
+ <div key={i} className="flex justify-between items-center text-slate-300 font-mono py-0.5">
142
+ <span className="text-red-400 truncate w-[40%]">{globalPlayers.find((p) => String(p.ID) === String(id))?.Name || id}</span>
143
+ <span className="text-slate-600 font-bold">»</span>
144
+ <span className="text-emerald-400 truncate w-[40%] text-right">{globalPlayers.find((p) => String(p.ID) === String(gwPlan.transfers_in[i]))?.Name || gwPlan.transfers_in[i]}</span>
145
+ </div>
146
+ ))}
147
+ </div>
148
+ )
149
+ ))}
150
+ </div>
151
+ <button onClick={() => applySolution(sol)} className="w-full bg-slate-800 hover:bg-luigi-500 hover:text-slate-950 text-luigi-400 font-bold py-2 rounded-lg transition-colors text-sm">Apply Path</button>
152
+ </div>
153
+ ))}
154
+ </div>
155
+ )}
156
+
157
+ {!isSolving && pendingSolutions.length === 0 && (
158
+ appliedPlanSummary ? (
159
+ <div className="flex flex-col gap-3 p-1">
160
+ <div className="flex items-center justify-between mb-1">
161
+ <h4 className="text-slate-300 font-bold text-xs uppercase tracking-wider">Last Applied · {appliedPlanSummary.horizon}</h4>
162
+ <button onClick={() => setAppliedPlanSummary(null)} className="text-slate-600 hover:text-red-400 text-xs font-bold">✕</button>
163
+ </div>
164
+ <div className="text-[10px] text-slate-500 font-mono">{getRelativeEv(appliedPlanSummary)} pts {appliedPlanSummary.objectiveScore != null && ` · eval ${appliedPlanSummary.objectiveScore.toFixed(2)}`}</div>
165
+ {appliedPlanSummary.transfers.map((t, i) => (
166
+ <div key={i} className="bg-slate-900 rounded-lg p-2.5 text-xs">
167
+ <div className="flex items-center justify-between gap-2 mb-1.5">
168
+ <div className="flex items-center gap-2">
169
+ <span className="text-slate-400 font-bold">GW {t.gw}</span>
170
+ {t.chip && CHIP_CONFIG[t.chip] && <span className={`text-[9px] px-1 py-0.5 rounded font-black ${CHIP_CONFIG[t.chip].badge}`}>{CHIP_CONFIG[t.chip].short}{t.gw}</span>}
171
+ </div>
172
+ <div className="flex gap-2 text-[9px] font-mono">
173
+ <span className="text-cyan-400">FT Spend: {t.chip === "wc" || t.chip === "fh" ? `${t.outs?.length || 0}/∞` : `${t.outs?.length || 0}/${t.ft_at_start ?? 1}${t.hits > 0 ? ` (-${t.hits * 4})` : ""}`}</span>
174
+ <span className="text-emerald-400">£{Math.abs(t.itb) < 0.05 ? "0.0" : Number(t.itb).toFixed(1)}m</span>
175
+ </div>
176
+ </div>
177
+ {t.outs.length === 0 && t.ins.length === 0 ? (
178
+ <span className="text-slate-600 italic text-[10px]">{t.chip ? `${CHIP_CONFIG[t.chip]?.label || t.chip} active` : 'Hold — no transfers'}</span>
179
+ ) : (
180
+ t.outs.map((name, j) => (
181
+ <div key={j} className="flex items-center gap-1 py-0.5 font-mono">
182
+ <span className="text-red-400 truncate flex-1">{name}</span>
183
+ <span className="text-slate-600 font-bold shrink-0">»</span>
184
+ <span className="text-emerald-400 truncate flex-1 text-right">{t.ins[j] || "?"}</span>
185
+ </div>
186
+ ))
187
+ )}
188
+ </div>
189
+ ))}
190
+ </div>
191
+ ) : (
192
+ <div className="flex flex-col items-center justify-center gap-3 min-h-[200px] text-slate-500 text-sm text-center px-4">
193
+ <Zap size={28} className="text-slate-700" />
194
+ Configure settings and hit <span className="text-luigi-400 font-bold">Solve</span> in the left panel.
195
+ </div>
196
+ )
197
+ )}
198
+ </div>
199
+ </div>
200
+ );
201
+ };