Spaces:
Running
Running
Commit ·
f7cecf3
0
Parent(s):
Clean Production Release
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +2 -0
- .github/workflows/keep_alive.yml +22 -0
- .github/workflows/sync.yml +17 -0
- .gitignore +48 -0
- Dockerfile +18 -0
- README.md +23 -0
- admin_fixture_overrides.json +11 -0
- admin_persistent_availability_multipliers.json +555 -0
- admin_persistent_share_overrides.json +1 -0
- admin_persistent_xmins_overrides.json +753 -0
- auth.py +205 -0
- database.py +50 -0
- engine.py +613 -0
- ewmapois_model.csv +72 -0
- fpl_api.py +88 -0
- fpl_streamlit_app.py +0 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +29 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +41 -0
- frontend/postcss.config.js +6 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/hero.png +0 -0
- frontend/public/icon.jpg +0 -0
- frontend/public/icons.svg +24 -0
- frontend/public/image.png +0 -0
- frontend/public/l-logo.png +0 -0
- frontend/public/luigismansion.jpg +0 -0
- frontend/src/App.css +184 -0
- frontend/src/App.jsx +280 -0
- frontend/src/PlayerContext.jsx +572 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/AccuracyDashboard.jsx +273 -0
- frontend/src/components/ActiveMovesPanel.jsx +94 -0
- frontend/src/components/AdvancedSettingsModal.jsx +254 -0
- frontend/src/components/DraftsComparisonTable.jsx +140 -0
- frontend/src/components/DraggablePlayer.jsx +72 -0
- frontend/src/components/FixtureMatrixPanel.jsx +375 -0
- frontend/src/components/Fixtures.jsx +143 -0
- frontend/src/components/LandingPage.jsx +76 -0
- frontend/src/components/LoginModal.jsx +231 -0
- frontend/src/components/PitchView.jsx +100 -0
- frontend/src/components/PlayerCardVisual.jsx +258 -0
- frontend/src/components/PlayerModals.jsx +286 -0
- frontend/src/components/ProjectionsTable.jsx +664 -0
- frontend/src/components/Solver.jsx +1806 -0
- 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< 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< 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 |
+
};
|