diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..d21277c6600e44bb82d48747693d646c05ae16f8
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+*.xlsx filter=lfs diff=lfs merge=lfs -text
+*.ttf filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/keep_alive.yml b/.github/workflows/keep_alive.yml
new file mode 100644
index 0000000000000000000000000000000000000000..2612ae9142836c0ef875637d6984bd1218952029
--- /dev/null
+++ b/.github/workflows/keep_alive.yml
@@ -0,0 +1,22 @@
+name: Keep App Alive
+
+on:
+ schedule:
+ - cron: '0 */8 * * *'
+ workflow_dispatch:
+
+jobs:
+ ping-repo:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write # Critical: Grants the action permission to push to your repo
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Push Empty Commit
+ run: |
+ git config --global user.name "GitHub Actions"
+ git config --global user.email "actions@github.com"
+ git commit --allow-empty -m "Auto-commit to keep app awake"
+ git push
\ No newline at end of file
diff --git a/.github/workflows/sync.yml b/.github/workflows/sync.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cce06516240f9f83ab478cc6ad67bf8f3568a5a4
--- /dev/null
+++ b/.github/workflows/sync.yml
@@ -0,0 +1,17 @@
+name: Sync to Hugging Face
+on:
+ push:
+ branches: [main, master]
+jobs:
+ sync-to-hub:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ lfs: true
+ - name: Push to Hugging Face
+ env:
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
+ # BE SURE TO REPLACE THE TWO PLACEHOLDERS BELOW!
+ run: git push --force https://AnayShukla:${HF_TOKEN}@huggingface.co/spaces/AnayShukla/fpl-solver HEAD:main
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..95857baf8306a517737315a03341c279553b42bd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,48 @@
+*
+
+!fpl_streamlit_app.py
+!statistical_weighted_baselines.csv
+!statistical_weighted_baselines_gk.csv
+!admin_persistent_xmins_overrides.json
+!admin_persistent_share_overrides.json
+!admin_persistent_availability_multipliers.json
+!ewmapois_model.csv
+!team_totals.xlsx
+!user_xmins_overrides.json
+!user_player_status_overrides.json
+!user_baseline_overrides.json
+!player_penalty_shares.json
+!player_groups.json
+!rename.json
+!rates_config.json
+!requirements.txt
+!README.md
+!.gitignore
+!.github
+!.github/workflows
+!.github/workflows/*
+!projections_check.xlsx
+!points_check.xlsx
+!logos/
+!logos/*
+!team_ratings_dual_speed.csv
+!frontend/*
+!frontend
+!frontend/src
+!frontend/src/*
+!frontend/src/*/*
+!frontend/public
+!frontend/public/*
+!main.py
+!engine.py
+!database.py
+!fpl_api.py
+!auth.py
+!solver.py
+!solver_engine.py
+!admin_fixture_overrides.json
+!Dockerfile
+!.gitattributes
+
+
+!LICENSE
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000000000000000000000000000000000000..e9f3c3456a44a167b4b579237dab4285628fa81c
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,18 @@
+# Use a lightweight Python x86 image
+FROM python:3.10-slim
+
+# Set the working directory inside the server
+WORKDIR /app
+
+# Copy your requirements first and install them
+COPY requirements.txt .
+RUN pip install --no-cache-dir -r requirements.txt
+
+# Copy all your Python code, CSVs, and logic into the server
+COPY . .
+
+# Hugging Face REQUIRES your app to run on port 7860
+EXPOSE 7860
+
+# The command to boot the server
+CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..9ccb4e8b7fb4845c572e097e226ff43d2692ce40
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+---
+title: Luigi's Mansion FPL Solver
+emoji: 👻
+colorFrom: green
+colorTo: purple
+sdk: docker
+app_port: 7860
+---
+
+
+
+## Luigi's Mansion
+Yes you can play around with xMins here (I made it more for my convenience rather than yours but nonetheless, enjoy!)
+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!
+
+### Instructions:
+
+- Use the sidebar to adjust player minutes, weekly or across the horizon.
+- Please wait 1-2s after pressing the Update button as your changes get processed and updated.
+- Currently, the overrides CANNOT be applied simultaneously, so don't forget to press the Update button else your changes will not be processed.
+- The Reset Override button ONLY resets the xMins of the player chosen. To revert back to original projections, simply reload the page.
+- 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.
+- You can download your customized projections as a CSV file (will be downloaded as 'luigis_mansion.csv').
\ No newline at end of file
diff --git a/admin_fixture_overrides.json b/admin_fixture_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..c393098b757e9bd7dd178864959386595c84530f
--- /dev/null
+++ b/admin_fixture_overrides.json
@@ -0,0 +1,11 @@
+{
+ "3_vs_13": {
+ "33": 1
+ },
+ "6_vs_7": {
+ "33": 1
+ },
+ "4_vs_11": {
+ "33": 1
+ }
+}
\ No newline at end of file
diff --git a/admin_persistent_availability_multipliers.json b/admin_persistent_availability_multipliers.json
new file mode 100644
index 0000000000000000000000000000000000000000..ba26de00e7152d79adede2c9007c8a00c78074ff
--- /dev/null
+++ b/admin_persistent_availability_multipliers.json
@@ -0,0 +1,555 @@
+{
+ "17": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.0,
+ "11": 0.0,
+ "12": 0.5
+ },
+ "173": {
+ "27": 0.5
+ },
+ "642": {
+ "8": 0.75,
+ "28": 0.0
+ },
+ "200": {
+ "8": 0.75
+ },
+ "596": {
+ "31": 0.6
+ },
+ "217": {
+ "8": 0.75
+ },
+ "237": {
+ "8": 0.75
+ },
+ "525": {
+ "8": 0.75
+ },
+ "316": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.0,
+ "11": 0.0,
+ "12": 0.0,
+ "13": 0.0,
+ "14": 0.0,
+ "15": 0.25
+ },
+ "30": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.0,
+ "11": 0.25,
+ "12": 0.5,
+ "13": 0.75,
+ "28": 0.6,
+ "29": 0.8
+ },
+ "31": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.0,
+ "11": 0.0,
+ "12": 0.0,
+ "13": 0.0,
+ "14": 0.0,
+ "15": 0.0,
+ "16": 0.0,
+ "17": 0.0,
+ "19": 1.0
+ },
+ "151": {
+ "4": 0.75
+ },
+ "232": {
+ "8": 0.75
+ },
+ "230": {
+ "28": 0.0
+ },
+ "135": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.5
+ },
+ "402": {
+ "8": 0.25,
+ "9": 0.5
+ },
+ "419": {
+ "17": 0.25,
+ "18": 1.0
+ },
+ "411": {
+ "29": 0.7
+ },
+ "235": {
+ "4": 0.5,
+ "6": 0.0,
+ "7": 0.0,
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.0,
+ "11": 0.25,
+ "12": 0.5,
+ "22": 0.93
+ },
+ "64": {
+ "24": 0.4
+ },
+ "712": {
+ "4": 0.75,
+ "6": 0.75
+ },
+ "293": {
+ "27": 0.0
+ },
+ "403": {
+ "4": 0.5,
+ "5": 1.0
+ },
+ "450": {
+ "4": 0.75,
+ "13": 0.0,
+ "14": 0.75
+ },
+ "508": {
+ "25": 0.0
+ },
+ "490": {
+ "4": 0.5
+ },
+ "654": {
+ "4": 0.75,
+ "5": 0.75
+ },
+ "299": {
+ "4": 0.5,
+ "23": 0.9
+ },
+ "48": {
+ "6": 0.5,
+ "5": 0.5,
+ "7": 0.0,
+ "8": 0.0,
+ "9": 0.25,
+ "10": 0.5,
+ "11": 0.5,
+ "12": 1.0
+ },
+ "488": {
+ "23": 0.8
+ },
+ "685": {
+ "5": 0.75
+ },
+ "85": {
+ "31": 0.0
+ },
+ "145": {
+ "5": 0.75
+ },
+ "506": {
+ "5": 0.5,
+ "6": 0.25,
+ "7": 0.5,
+ "13": 0.75
+ },
+ "552": {
+ "5": 0.75
+ },
+ "547": {
+ "31": 0.7
+ },
+ "16": {
+ "25": 0.0,
+ "26": 0.0
+ },
+ "457": {
+ "17": 0.0
+ },
+ "249": {
+ "6": 0.75
+ },
+ "32": {
+ "7": 0.25
+ },
+ "40": {
+ "8": 0.75,
+ "7": 0.0
+ },
+ "152": {
+ "7": 0.5
+ },
+ "157": {
+ "7": 0.25,
+ "8": 0.75,
+ "10": 0.75,
+ "9": 0.5,
+ "11": 0.0,
+ "12": 0.0,
+ "13": 0.25,
+ "14": 0.0,
+ "15": 0.5,
+ "16": 0.75,
+ "19": 0.5,
+ "31": 0.9
+ },
+ "226": {
+ "7": 0.0,
+ "14": 0.25
+ },
+ "242": {
+ "7": 0.0,
+ "23": 0.8
+ },
+ "337": {
+ "30": 0.5
+ },
+ "332": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.5
+ },
+ "338": {
+ "8": 0.0,
+ "9": 0.0,
+ "10": 0.5
+ },
+ "97": {
+ "9": 0.0,
+ "10": 0.5,
+ "11": 1.0
+ },
+ "317": {
+ "9": 0.0,
+ "10": 1.0,
+ "11": 1.0
+ },
+ "329": {
+ "29": 0.65,
+ "30": 0.7
+ },
+ "413": {
+ "9": 0.5,
+ "16": 0.5
+ },
+ "5": {
+ "9": 0.5,
+ "10": 1.0,
+ "12": 0.25,
+ "13": 0.5,
+ "18": 0.25,
+ "19": 0.9
+ },
+ "6": {
+ "10": 0.0,
+ "11": 1.0,
+ "14": 0.0,
+ "15": 0.25,
+ "16": 0.5
+ },
+ "381": {
+ "31": 0.0,
+ "32": 0.8
+ },
+ "382": {
+ "28": 0.45,
+ "29": 0.4,
+ "30": 0.65
+ },
+ "666": {
+ "11": 0.0,
+ "12": 0.5,
+ "13": 0.25,
+ "14": 0.5
+ },
+ "691": {
+ "30": 0.75
+ },
+ "612": {
+ "12": 0.0,
+ "21": 0.0,
+ "22": 0.75
+ },
+ "82": {
+ "13": 1.0,
+ "12": 0.75,
+ "30": 0.8
+ },
+ "476": {
+ "12": 0.0
+ },
+ "375": {
+ "12": 0.0,
+ "13": 0.0,
+ "14": 0.0
+ },
+ "302": {
+ "13": 0.0,
+ "14": 0.0,
+ "15": 0.0,
+ "16": 0.0,
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0
+ },
+ "661": {
+ "13": 0.5,
+ "21": 0.45,
+ "22": 1.0
+ },
+ "515": {
+ "13": 0.75
+ },
+ "565": {
+ "31": 0.0
+ },
+ "569": {
+ "13": 0.0,
+ "18": 0.0,
+ "26": 0.0,
+ "27": 0.0,
+ "28": 0.0,
+ "29": 0.0,
+ "30": 0.0
+ },
+ "72": {
+ "14": 0.0,
+ "16": 0.75
+ },
+ "267": {
+ "14": 0.0,
+ "15": 0.0,
+ "16": 0.75
+ },
+ "241": {
+ "14": 0.0,
+ "15": 0.0,
+ "16": 0.0
+ },
+ "469": {
+ "14": 0.0,
+ "15": 0.0
+ },
+ "660": {
+ "14": 0.0,
+ "15": 0.5,
+ "23": 0.8,
+ "25": 0.0,
+ "26": 0.0
+ },
+ "236": {
+ "25": 0.6,
+ "26": 0.95,
+ "30": 0.0
+ },
+ "554": {
+ "14": 0.0,
+ "15": 0.0
+ },
+ "295": {
+ "15": 0.25,
+ "16": 1.0,
+ "20": 0.75,
+ "22": 0.0,
+ "23": 0.0
+ },
+ "256": {
+ "16": 0.75,
+ "30": 0.8
+ },
+ "257": {
+ "29": 0.0
+ },
+ "384": {
+ "16": 0.0,
+ "17": 0.0,
+ "18": 0.5,
+ "23": 0.8
+ },
+ "8": {
+ "19": 0.75,
+ "30": 0.98
+ },
+ "120": {
+ "16": 0.0,
+ "25": 0.0,
+ "26": 0.0
+ },
+ "365": {
+ "16": 0.0,
+ "17": 0.0
+ },
+ "456": {
+ "17": 0.0
+ },
+ "124": {
+ "17": 0.75
+ },
+ "146": {
+ "17": 0.0
+ },
+ "169": {
+ "17": 0.0
+ },
+ "694": {
+ "28": 0.75
+ },
+ "178": {
+ "17": 0.5
+ },
+ "387": {
+ "18": 0.0,
+ "26": 0.0
+ },
+ "449": {
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.34,
+ "22": 1.0
+ },
+ "20": {
+ "26": 0.6
+ },
+ "347": {
+ "23": 0.9,
+ "31": 0.0
+ },
+ "717": {
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0
+ },
+ "19": {
+ "18": 0.0
+ },
+ "261": {
+ "18": 0.5,
+ "19": 0.36,
+ "20": 0.62
+ },
+ "374": {
+ "18": 0.5
+ },
+ "7": {
+ "20": 0.0,
+ "21": 0.2,
+ "22": 0.6,
+ "23": 0.6
+ },
+ "36": {
+ "31": 0.7
+ },
+ "58": {
+ "19": 0.0
+ },
+ "643": {
+ "19": 0.0,
+ "26": 0.7
+ },
+ "113": {
+ "19": 0.75
+ },
+ "455": {
+ "19": 0.65,
+ "20": 0.6,
+ "21": 0.5
+ },
+ "21": {
+ "20": 0.0,
+ "21": 1.0,
+ "29": 0.7
+ },
+ "354": {
+ "20": 0.0
+ },
+ "670": {
+ "29": 0.0,
+ "30": 0.0
+ },
+ "673": {
+ "30": 0.0
+ },
+ "796": {
+ "30": 0.7
+ },
+ "531": {
+ "20": 0.46,
+ "31": 0.8
+ },
+ "220": {
+ "21": 0.6
+ },
+ "322": {
+ "21": 0.23,
+ "22": 0.6,
+ "24": 0.8,
+ "25": 0.9,
+ "31": 0.75
+ },
+ "342": {
+ "21": 0.35
+ },
+ "224": {
+ "21": 0.33,
+ "27": 0.0,
+ "28": 0.3,
+ "29": 0.5
+ },
+ "582": {
+ "21": 0.3,
+ "22": 0.7
+ },
+ "12": {
+ "22": 0.0
+ },
+ "260": {
+ "22": 0.0
+ },
+ "84": {
+ "23": 0.0,
+ "24": 0.0,
+ "25": 0.0,
+ "28": 0.45,
+ "29": 0.92
+ },
+ "283": {
+ "24": 0.0
+ },
+ "407": {
+ "23": 0.3
+ },
+ "414": {
+ "23": 0.86
+ },
+ "430": {
+ "23": 0.91,
+ "29": 0.8
+ },
+ "108": {
+ "24": 0.75
+ },
+ "121": {
+ "24": 0.75
+ },
+ "290": {
+ "24": 0.3
+ },
+ "575": {
+ "30": 0.0
+ },
+ "709": {
+ "24": 0.5
+ },
+ "807": {
+ "24": 0.5
+ },
+ "609": {
+ "25": 0.0
+ }
+}
\ No newline at end of file
diff --git a/admin_persistent_share_overrides.json b/admin_persistent_share_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..9e26dfeeb6e641a33dae4961196235bdb965b21b
--- /dev/null
+++ b/admin_persistent_share_overrides.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/admin_persistent_xmins_overrides.json b/admin_persistent_xmins_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..655cc221da0f041a291ee928d224ec1568c0c10f
--- /dev/null
+++ b/admin_persistent_xmins_overrides.json
@@ -0,0 +1,753 @@
+{
+ "1": {
+ "1": 88.19
+ },
+ "33": {
+ "1": 87.36,
+ "7": 88.77
+ },
+ "338": {
+ "30": 72.0
+ },
+ "81": {
+ "1": 22.34
+ },
+ "101": {
+ "1": 87.57
+ },
+ "596": {
+ "1": 33.87
+ },
+ "136": {
+ "1": 82.3
+ },
+ "5": {
+ "1": 76.18
+ },
+ "120": {
+ "1": 53.13
+ },
+ "597": {
+ "1": 73.0
+ },
+ "41": {
+ "2": 82.54,
+ "7": 78.37
+ },
+ "235": {
+ "18": 75.0,
+ "19": 40.0,
+ "20": 74.0
+ },
+ "238": {
+ "3": 56.55,
+ "6_vs_7": 63.0
+ },
+ "16": {
+ "3": 0.0,
+ "7": 78.73,
+ "32": 52.0
+ },
+ "17": {
+ "3": 0.0,
+ "32": 61.0,
+ "33": 64.0
+ },
+ "474": {
+ "3": 65.2
+ },
+ "491": {
+ "3": 28.37
+ },
+ "654": {
+ "3": 55.47
+ },
+ "18": {
+ "3": 78.04,
+ "4": 77.04,
+ "5": 75.53,
+ "25": 85.0,
+ "26": 83.0
+ },
+ "271": {
+ "4": 77.03,
+ "5": 75.09
+ },
+ "341": {
+ "4": 87.0,
+ "5": 86.0
+ },
+ "665": {
+ "4": 0.0,
+ "5": 0.0
+ },
+ "691": {
+ "5": 75.35,
+ "4": 78.54,
+ "6": 67.43
+ },
+ "733": {
+ "4": 0.0
+ },
+ "430": {
+ "30": 74.0
+ },
+ "431": {
+ "4": 86.43
+ },
+ "456": {
+ "6": 42.43,
+ "32": 12.0
+ },
+ "677": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "697": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "83": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "167": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "198": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "207": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "217": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "266": {
+ "8": 80,
+ "9": 79,
+ "10": 78,
+ "11": 75,
+ "12": 73,
+ "32": 34.0
+ },
+ "267": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "268": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "318": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "324": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "727": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "381": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "402": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "3_vs_13": 34.0
+ },
+ "413": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "119": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "438": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "452": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "299": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0,
+ "32": 76.0
+ },
+ "302": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "521": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "541": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "32": 83.0
+ },
+ "543": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "544": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "552": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "553": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "678": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "735": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "603": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0,
+ "22": 0.0
+ },
+ "631": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "648": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "695": {
+ "17": 0.0,
+ "18": 0.0,
+ "19": 0.0,
+ "20": 0.0,
+ "21": 0.0
+ },
+ "568": {
+ "6": 72.35
+ },
+ "407": {
+ "6": 63.56
+ },
+ "411": {
+ "24": 72.0,
+ "30": 42.0
+ },
+ "367": {
+ "7": 88.42,
+ "8": 88.42,
+ "9": 88.42,
+ "10": 88.42,
+ "11": 88.42,
+ "12": 88.0,
+ "32": 90.0,
+ "33": 90.0,
+ "34": 90.0,
+ "35": 90.0,
+ "36": 90.0,
+ "37": 9.0
+ },
+ "100": {
+ "9": 71.0,
+ "10": 64.0,
+ "32": 49.0,
+ "4_vs_11": 59.0
+ },
+ "252": {
+ "9": 48.0
+ },
+ "22": {
+ "11": 82.0,
+ "13": 83.0
+ },
+ "10": {
+ "16": 81.0
+ },
+ "570": {
+ "13": 84.0,
+ "24": 81.0,
+ "30": 88.0
+ },
+ "572": {
+ "30": 84.0
+ },
+ "567": {
+ "31": 90.0
+ },
+ "573": {
+ "30": 84.0
+ },
+ "725": {
+ "14": 86.0,
+ "15": 84.0,
+ "16": 85.0,
+ "17": 82.0,
+ "18": 67.0,
+ "19": 77.0,
+ "20": 80.0,
+ "21": 78.0,
+ "22": 75.0
+ },
+ "662": {
+ "14": 84.0
+ },
+ "721": {
+ "14": 86.0
+ },
+ "674": {
+ "14": 84.0,
+ "15": 84.0,
+ "17": 88.0,
+ "18": 85.0
+ },
+ "7": {
+ "16": 0.0
+ },
+ "263": {
+ "16": 87.0,
+ "17": 86.0,
+ "18": 84.0,
+ "19": 82.0,
+ "20": 67.0,
+ "21": 58.0,
+ "22": 65.0
+ },
+ "447": {
+ "16": 74.0,
+ "17": 68.0,
+ "18": 60.0
+ },
+ "11": {
+ "16": 83.0,
+ "32": 58.0
+ },
+ "30": {
+ "18": 0.0,
+ "32": 63.0
+ },
+ "295": {
+ "20": 47.0,
+ "21": 88.0,
+ "24": 85.0
+ },
+ "719": {
+ "20": 85.0,
+ "21": 83.0,
+ "22": 80.0
+ },
+ "321": {
+ "21": 87.0,
+ "22": 85.0
+ },
+ "319": {
+ "21": 77.0,
+ "22": 76.0
+ },
+ "405": {
+ "21": 76.0,
+ "22": 75.0
+ },
+ "406": {
+ "21": 78.0,
+ "22": 77.0,
+ "23": 76.0,
+ "24": 75.0,
+ "32": 68.0,
+ "13_vs_1": 69.0,
+ "3_vs_13": 21.0
+ },
+ "808": {
+ "24": 75.0
+ },
+ "113": {
+ "24": 80.0
+ },
+ "712": {
+ "24": 79.0
+ },
+ "273": {
+ "24": 0.0
+ },
+ "814": {
+ "25": 52.0
+ },
+ "400": {
+ "26": 90.0,
+ "27": 90.0,
+ "28": 90.0
+ },
+ "723": {
+ "26": 85.0,
+ "27": 85.0
+ },
+ "812": {
+ "29": 90.0,
+ "30": 90.0
+ },
+ "20": {
+ "32": 52.0
+ },
+ "48": {
+ "32": 26.0,
+ "33": 44.0
+ },
+ "143": {
+ "32": 71.0,
+ "35": 24.0,
+ "18_vs_6": 75.0,
+ "6_vs_7": 1.0
+ },
+ "146": {
+ "32": 0.0,
+ "18_vs_6": 7.0
+ },
+ "160": {
+ "32": 62.0,
+ "6_vs_7": 71.0
+ },
+ "163": {
+ "32": 5.0,
+ "18_vs_6": 4.0
+ },
+ "157": {
+ "32": 84.0,
+ "18_vs_6": 84.0,
+ "6_vs_7": 75.0
+ },
+ "173": {
+ "32": 82.0,
+ "6_vs_7": 82.0
+ },
+ "178": {
+ "32": 77.0,
+ "6_vs_7": 65.0,
+ "18_vs_6": 78.0
+ },
+ "85": {
+ "32": 0.0
+ },
+ "109": {
+ "32": 0.0,
+ "33": 0.0
+ },
+ "231": {
+ "32": 32.0
+ },
+ "232": {
+ "32": 67.0,
+ "6_vs_7": 47.0
+ },
+ "237": {
+ "32": 0.0,
+ "7_vs_14": 51.0
+ },
+ "672": {
+ "32": 68.0
+ },
+ "224": {
+ "32": 35.0,
+ "6_vs_7": 64.0,
+ "7_vs_14": 79.0
+ },
+ "342": {
+ "32": 74.0,
+ "4_vs_11": 47.0
+ },
+ "348": {
+ "32": 68.0
+ },
+ "356": {
+ "32": 73.0
+ },
+ "660": {
+ "32": 0.0,
+ "11_vs_20": 0.0,
+ "4_vs_11": 0.0,
+ "35": 70.0
+ },
+ "366": {
+ "32": 0.0,
+ "33": 0.0,
+ "34": 0.0,
+ "35": 0.0,
+ "36": 0.0,
+ "37": 81.0
+ },
+ "442": {
+ "32": 0.0
+ },
+ "475": {
+ "32": 0.0,
+ "33": 0.0,
+ "34": 0.0,
+ "35": 0.0,
+ "36": 0.0,
+ "37": 20.0
+ },
+ "488": {
+ "32": 0.0
+ },
+ "497": {
+ "32": 0.0
+ },
+ "531": {
+ "32": 53.0
+ },
+ "326": {
+ "32": 70.0
+ },
+ "609": {
+ "32": 0.0
+ },
+ "615": {
+ "32": 60.0,
+ "33": 71.0,
+ "34": 75.0
+ },
+ "606": {
+ "32": 26.0
+ },
+ "791": {
+ "32": 80.0
+ },
+ "21": {
+ "32": 84.0
+ },
+ "8": {
+ "32": 54.0
+ },
+ "152": {
+ "35": 30.0
+ },
+ "169": {
+ "32": 72.0,
+ "18_vs_6": 10.0
+ },
+ "110": {
+ "32": 81.0
+ },
+ "116": {
+ "32": 63.0
+ },
+ "220": {
+ "36": 82.0,
+ "37": 80.0,
+ "38": 78.0
+ },
+ "228": {
+ "32": 71.0,
+ "7_vs_14": 75.0,
+ "6_vs_7": 68.0
+ },
+ "230": {
+ "32": 78.0,
+ "7_vs_14": 77.0,
+ "6_vs_7": 69.0
+ },
+ "347": {
+ "32": 78.0,
+ "11_vs_20": 88.0,
+ "4_vs_11": 88.0
+ },
+ "350": {
+ "32": 77.0,
+ "11_vs_20": 75.0,
+ "4_vs_11": 73.0
+ },
+ "370": {
+ "32": 47.0
+ },
+ "408": {
+ "32": 0.0,
+ "13_vs_1": 3.0,
+ "3_vs_13": 73.0
+ },
+ "441": {
+ "32": 10.0
+ },
+ "716": {
+ "33": 0.0
+ },
+ "694": {
+ "32": 77.0
+ },
+ "813": {
+ "32": 0.0
+ },
+ "417": {
+ "13_vs_1": 72.0,
+ "3_vs_13": 73.0
+ },
+ "666": {
+ "32": 63.0
+ },
+ "148": {
+ "18_vs_6": 86.0,
+ "6_vs_7": 86.0
+ },
+ "151": {
+ "18_vs_6": 87.0,
+ "6_vs_7": 87.0
+ },
+ "158": {
+ "6_vs_7": 71.0
+ },
+ "783": {
+ "6_vs_7": 85.0
+ },
+ "90": {
+ "4_vs_11": 70.0,
+ "15_vs_4": 72.0
+ },
+ "97": {
+ "4_vs_11": 66.0,
+ "15_vs_4": 79.0
+ },
+ "200": {
+ "16_vs_3": 67.0,
+ "3_vs_13": 61.0
+ },
+ "215": {
+ "3_vs_13": 42.0
+ },
+ "225": {
+ "7_vs_14": 2.0
+ },
+ "226": {
+ "35": 0.0
+ },
+ "236": {
+ "6_vs_7": 47.0
+ },
+ "453": {
+ "6_vs_7": 76.0
+ },
+ "365": {
+ "11_vs_20": 18.0,
+ "4_vs_11": 27.0
+ }
+}
\ No newline at end of file
diff --git a/auth.py b/auth.py
new file mode 100644
index 0000000000000000000000000000000000000000..7179d495455c0e78b1a9082a632605997287c8e0
--- /dev/null
+++ b/auth.py
@@ -0,0 +1,205 @@
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy.orm import Session
+from pydantic import BaseModel
+import bcrypt
+from jose import jwt
+from datetime import datetime, timedelta
+from google.oauth2 import id_token
+from google.auth.transport import requests
+from sqlalchemy.orm.attributes import flag_modified
+from database import User, get_db
+from fastapi.security import OAuth2PasswordBearer
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/auth/login")
+
+router = APIRouter(prefix="/api/auth", tags=["auth"])
+
+# --- SECURITY CONFIG ---
+SECRET_KEY = "super_secret_luigi_key_change_this_later_in_production"
+ALGORITHM = "HS256"
+# You will get this ID from Google Cloud Console later
+GOOGLE_CLIENT_ID = (
+ "525088967752-vhdm44u6qddh5ldot4p1hibe1k0f7mk2.apps.googleusercontent.com"
+)
+
+
+# --- PYDANTIC MODELS (Payloads) ---
+class UserCreate(BaseModel):
+ email: str
+ password: str
+
+
+class UserLogin(BaseModel):
+ email: str
+ password: str
+
+
+class GoogleLogin(BaseModel):
+ token: str
+
+
+# --- HELPER FUNCTIONS ---
+def create_access_token(data: dict):
+ to_encode = data.copy()
+ expire = datetime.utcnow() + timedelta(days=7) # Stay logged in for 7 days
+ to_encode.update({"exp": expire})
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
+
+
+def check_admin_status(email: str):
+ """The magic function that makes you God"""
+ if email.lower() == "anayshukla11@gmail.com":
+ return True
+ return False
+
+
+# --- ROUTES ---
+
+
+@router.post("/register")
+def register_user(user: UserCreate, db: Session = Depends(get_db)):
+ # 1. Check if email exists
+ db_user = db.query(User).filter(User.email == user.email).first()
+ if db_user:
+ raise HTTPException(status_code=400, detail="Email already registered")
+
+ # 2. Hash password directly with bcrypt
+ salt = bcrypt.gensalt()
+ hashed_pw = bcrypt.hashpw(user.password.encode("utf-8"), salt).decode("utf-8")
+
+ # 3. Check if it is the admin email
+ is_admin = check_admin_status(user.email)
+
+ # 4. Save to DB
+ new_user = User(email=user.email, hashed_password=hashed_pw, is_admin=is_admin)
+ db.add(new_user)
+ db.commit()
+ db.refresh(new_user)
+
+ # 5. Issue Token
+ token = create_access_token(
+ {"sub": new_user.email, "role": "admin" if is_admin else "user"}
+ )
+ return {
+ "access_token": token,
+ "token_type": "bearer",
+ "email": new_user.email,
+ "is_admin": is_admin,
+ }
+
+
+@router.post("/login")
+def login_user(user: UserLogin, db: Session = Depends(get_db)):
+ # 1. Fetch user
+ db_user = db.query(User).filter(User.email == user.email).first()
+ if not db_user or not db_user.hashed_password:
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+
+ # 2. Verify password directly with bcrypt
+ if not bcrypt.checkpw(
+ user.password.encode("utf-8"), db_user.hashed_password.encode("utf-8")
+ ):
+ raise HTTPException(status_code=401, detail="Invalid credentials")
+
+ # 3. Issue Token
+ token = create_access_token(
+ {"sub": db_user.email, "role": "admin" if db_user.is_admin else "user"}
+ )
+ return {
+ "access_token": token,
+ "token_type": "bearer",
+ "email": db_user.email,
+ "is_admin": db_user.is_admin,
+ }
+
+
+@router.post("/google")
+def google_auth(payload: GoogleLogin, db: Session = Depends(get_db)):
+ try:
+ # Verify the token Google's frontend sent us
+ # THE FIX: Added clock_skew_in_seconds=10 to forgive slight time differences!
+ idinfo = id_token.verify_oauth2_token(
+ payload.token,
+ requests.Request(),
+ GOOGLE_CLIENT_ID,
+ clock_skew_in_seconds=10,
+ )
+ email = idinfo["email"]
+
+ # Check if user exists
+ db_user = db.query(User).filter(User.email == email).first()
+
+ # If they don't exist, register them silently via Google
+ if not db_user:
+ is_admin = check_admin_status(email)
+ db_user = User(
+ email=email, is_admin=is_admin
+ ) # No password needed for Google auth
+ db.add(db_user)
+ db.commit()
+ db.refresh(db_user)
+
+ token = create_access_token(
+ {"sub": db_user.email, "role": "admin" if db_user.is_admin else "user"}
+ )
+ return {
+ "access_token": token,
+ "token_type": "bearer",
+ "email": db_user.email,
+ "is_admin": db_user.is_admin,
+ }
+
+ except ValueError as e:
+ # THIS WILL TELL YOU EXACTLY WHY IT FAILED
+ print(f"GOOGLE AUTH ERROR: {str(e)}")
+ raise HTTPException(status_code=401, detail=f"Google Error: {str(e)}")
+
+
+def get_current_user(
+ token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
+):
+ try:
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
+ email = payload.get("sub")
+ if email is None:
+ raise HTTPException(status_code=401)
+ except:
+ raise HTTPException(status_code=401)
+
+ user = db.query(User).filter(User.email == email).first()
+ if user is None:
+ raise HTTPException(status_code=401)
+ return user
+
+
+@router.get("/me")
+def get_user_me(current_user: User = Depends(get_current_user)):
+ return {
+ "email": current_user.email,
+ "is_admin": current_user.is_admin,
+ "default_team_id": current_user.default_team_id,
+ "saved_edits": current_user.saved_edits,
+ "drafts": current_user.drafts, # <-- NEW: Send realities to React
+ }
+
+
+@router.post("/save_session")
+def save_session(
+ payload: dict,
+ current_user: User = Depends(get_current_user),
+ db: Session = Depends(get_db),
+):
+ if "default_team_id" in payload:
+ current_user.default_team_id = payload["default_team_id"]
+
+ if "saved_edits" in payload:
+ current_user.saved_edits = payload["saved_edits"]
+ flag_modified(current_user, "saved_edits")
+
+ # THE FIX: Catch and permanently save the Multiverse array
+ if "drafts" in payload:
+ current_user.drafts = payload["drafts"]
+ flag_modified(current_user, "drafts")
+
+ db.commit()
+ return {"status": "success"}
diff --git a/database.py b/database.py
new file mode 100644
index 0000000000000000000000000000000000000000..24bbf3dfcb4766f6d4314fe116191f563cb46e8c
--- /dev/null
+++ b/database.py
@@ -0,0 +1,50 @@
+import os
+from sqlalchemy import create_engine, Column, Integer, String, Boolean, JSON
+from sqlalchemy.orm import sessionmaker, declarative_base
+
+# Paste your Supabase URI here. Replace [YOUR-PASSWORD] with your actual password!
+SUPABASE_URL = "postgresql://postgres.gjbfbkhygtqubvpbquws:Anayshukla11$$@aws-1-ap-south-1.pooler.supabase.com:6543/postgres"
+
+# SQLAlchemy requires the URL to start with 'postgresql://'
+if SUPABASE_URL.startswith("postgres://"):
+ SUPABASE_URL = SUPABASE_URL.replace("postgres://", "postgresql://", 1)
+
+engine = create_engine(SUPABASE_URL, pool_pre_ping=True)
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+Base = declarative_base()
+
+
+# --- THE USER DATABASE MODEL ---
+class User(Base):
+ __tablename__ = "users"
+
+ id = Column(Integer, primary_key=True, index=True)
+ email = Column(String, unique=True, index=True)
+ hashed_password = Column(String, nullable=True)
+ is_admin = Column(Boolean, default=False)
+
+ # User FPL State
+ default_team_id = Column(Integer, nullable=True)
+ saved_edits = Column(JSON, default={})
+ drafts = Column(JSON, default=[])
+ solver_settings = Column(JSON, default={"quick": {}, "advanced": {}})
+
+
+# --- THE NEW JSON VAULT ---
+class GlobalConfig(Base):
+ __tablename__ = "global_config"
+ key = Column(String, primary_key=True, index=True)
+ value = Column(JSON, default={})
+
+
+# Create the tables in the Supabase database
+Base.metadata.create_all(bind=engine)
+
+
+# Dependency to get the DB session in our API routes
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/engine.py b/engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..54056a7b788d23979cfb49d7c44d201ea7e62837
--- /dev/null
+++ b/engine.py
@@ -0,0 +1,613 @@
+import pandas as pd
+import numpy as np
+import math
+from scipy.stats import nbinom
+
+
+def poisson_probability_of_conceding_2_or_more_goals(lambd):
+ """Calculates the probability of conceding 2 or more goals using Poisson distribution."""
+ p_0 = math.exp(-lambd)
+ p_1 = lambd * math.exp(-lambd)
+ return 1 - p_0 - p_1
+
+
+def poisson_pmf(k, lambd):
+ """Calculates the Poisson Probability Mass Function P(X=k)."""
+ if k < 0:
+ return 0.0
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
+ return 1.0 if k == 0 else 0.0
+ return (lambd**k * math.exp(-lambd)) / math.factorial(k)
+
+
+def neg_binom_probability_of_value(expected_mean, value, dispersion=1.0):
+ """
+ Calculates the exact probability (PMF) of getting exactly 'value' events.
+ Used for: Saves, Goals, Assists.
+ """
+ if expected_mean <= 0:
+ return 0.0
+ if dispersion <= 1.0: # Fallback to Poisson if no dispersion
+ return poisson_pmf(value, expected_mean)
+
+ # Convert Mean + Dispersion to n, p
+ p = 1 / dispersion
+ n = (expected_mean * p) / (1 - p)
+
+ return nbinom.pmf(value, n, p)
+
+
+def neg_binom_probability_at_least(expected_mean, threshold, dispersion=1.0):
+ """
+ Calculates probability of getting 'threshold' OR MORE events.
+ Used for: DefCons (CBIT), Recoveries.
+ """
+ if expected_mean <= 0:
+ return 0.0
+ if dispersion <= 1.0:
+ # Use existing Poisson logic if dispersion is low
+ return 1 - poisson_cdf(threshold - 1, expected_mean)
+
+ p = 1 / dispersion
+ n = (expected_mean * p) / (1 - p)
+
+ # Probability of X >= threshold is (1 - CDF(threshold - 1))
+ return 1 - nbinom.cdf(threshold - 1, n, p)
+
+
+def calculate_expected_conceded_points(lambd):
+ """
+ Calculates the expected fantasy points from goals conceded based on a
+ -1 point penalty for every 2 goals.
+ """
+ total_expected_points = 0
+ max_goals_to_check = 10
+
+ for k in range(max_goals_to_check + 1):
+ prob_k = poisson_pmf(k=k, lambd=lambd)
+ points_for_k_goals = -(k // 2)
+ total_expected_points += prob_k * points_for_k_goals
+
+ return total_expected_points
+
+
+def poisson_cdf(k, lambd):
+ """Calculates the Poisson Cumulative Distribution Function P(X<=k)."""
+ if k < 0:
+ return 0.0
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
+ return 1.0 if k >= 0 else 0.0
+ return sum(poisson_pmf(i, lambd) for i in range(math.floor(k) + 1))
+
+
+def apply_team_skepticism(df, skepticism_factors):
+ """
+ Applies a skepticism multiplier to a player's base points based on their team.
+ """
+ if not skepticism_factors:
+ return df
+
+ for team_id, multiplier in skepticism_factors.items():
+ players_on_team = df[df["team"] == team_id].index
+ df.loc[players_on_team, "base_pts"] *= multiplier
+
+ return df
+
+
+def calculate_single_match_points(
+ player,
+ match_row,
+ xMins_in_match,
+ points_config,
+ player_penalty_shares,
+ is_gk=False,
+ is_def=False,
+ is_mid=False,
+ is_fwd=False,
+):
+ """
+ Calculates points for a single match given the xMins and match projections.
+ Includes full logic for CBIT, CBITR, Penalty Saves, and dynamic BPS.
+ """
+ if xMins_in_match <= 0:
+ return {"pts": 0.0, "xG": 0.0, "xA": 0.0, "CS": 0.0, "cbit": 0.0, "cbitr": 0.0}
+
+ scaling_factor = xMins_in_match / 90.0
+ player_team_num = player["team"]
+ player_pos = player["element_type"]
+
+ # 1. Identify Home/Away and get Opponent Stats
+ if player_team_num == match_row["home_team_num"]:
+ team_proj_goals = match_row["mc_home_goals_mean"]
+ team_conc_goals = match_row["mc_away_goals_mean"]
+ team_proj_assists = match_row["mc_home_assists_xa_mean"]
+ team_proj_cbit = match_row["mc_home_CBIT_mean"]
+ team_proj_cbitr = match_row["mc_home_CBITR_mean"]
+ team_proj_saves = match_row["mc_home_keeper_saves_mean"]
+ team_proj_yc = match_row["mc_home_yc_mean"]
+ team_proj_rc = match_row["mc_home_rc_mean"]
+ cs_odds = match_row["home_clean_sheet_odds"]
+ else:
+ team_proj_goals = match_row["mc_away_goals_mean"]
+ team_conc_goals = match_row["mc_home_goals_mean"]
+ team_proj_assists = match_row["mc_away_assists_xa_mean"]
+ team_proj_cbit = match_row["mc_away_CBIT_mean"]
+ team_proj_cbitr = match_row["mc_away_CBITR_mean"]
+ team_proj_saves = match_row["mc_away_keeper_saves_mean"]
+ team_proj_yc = match_row["mc_away_yc_mean"]
+ team_proj_rc = match_row["mc_away_rc_mean"]
+ cs_odds = match_row["away_clean_sheet_odds"]
+
+ # 2. Player Share Calculations
+ proj_goals = player["xG_share"] * team_proj_goals
+ proj_assists = player["xA_share"] * team_proj_assists
+ proj_cbit = player["xCBIT_share"] * team_proj_cbit
+ proj_cbitr = player["xCBITR_share"] * team_proj_cbitr
+
+ proj_saves = 0
+ proj_pen_saves = 0
+ if is_gk:
+ proj_saves = (player["baseline_xSaves_p90"] + team_proj_saves) / 2
+ proj_pen_saves = player["baseline_pksave_p90"]
+
+ # --- GOALS & ASSISTS ---
+ pts_goals = (
+ sum(
+ poisson_pmf(k, proj_goals) * k * points_config["goal"][player_pos]
+ for k in range(9)
+ )
+ * scaling_factor
+ )
+ pts_assists = (
+ sum(
+ poisson_pmf(k, proj_assists) * k * points_config["assist"] for k in range(9)
+ )
+ * scaling_factor
+ )
+
+ # --- CLEAN SHEET & CONCEDED ---
+ pts_cs = (
+ cs_odds * points_config["clean_sheet"][player_pos]
+ if xMins_in_match >= 60
+ else (cs_odds * points_config["clean_sheet"][player_pos]) * scaling_factor
+ )
+ pts_conc = (
+ calculate_expected_conceded_points(team_conc_goals) * scaling_factor
+ if (is_gk or is_def) and team_conc_goals is not None
+ else 0.0
+ )
+
+ # --- CARDS ---
+ pts_yc = (player["YC_share"] * team_proj_yc * -1) * scaling_factor
+ pts_rc = (player["RC_share"] * team_proj_rc * -3) * scaling_factor
+
+ # --- SAVES & PENALTY SAVES (GK) ---
+ pts_saves = 0.0
+ pts_pen_save = 0.0
+ if is_gk:
+ expected_saves_pts_unscaled = sum(
+ neg_binom_probability_of_value(proj_saves, k, dispersion=1.5)
+ * ((k // 3) * points_config["saves_per_3"])
+ for k in range(21)
+ )
+ pts_saves = expected_saves_pts_unscaled * scaling_factor
+ expected_pen_saved_pts_unscaled = sum(
+ poisson_pmf(k, proj_pen_saves) * (k * 5) for k in range(3)
+ )
+ pts_pen_save = expected_pen_saved_pts_unscaled * scaling_factor
+
+ # --- CBIT & CBITR ---
+ pts_cbit = (
+ (
+ neg_binom_probability_at_least(proj_cbit, 10, dispersion=3.2)
+ * 2
+ * scaling_factor
+ )
+ if is_def
+ else 0.0
+ )
+ pts_cbitr = 0.0
+ if is_mid:
+ pts_cbitr = (
+ neg_binom_probability_at_least(proj_cbitr, 12, dispersion=2.8)
+ * 2
+ * scaling_factor
+ )
+ elif is_fwd:
+ pts_cbitr = (
+ neg_binom_probability_at_least(proj_cbitr, 12, dispersion=1.7)
+ * 2
+ * scaling_factor
+ )
+
+ # --- PENALTY POINTS (Taker) ---
+ pts_penalty = 0.0
+ if player_penalty_shares and player["id"] in player_penalty_shares:
+ pen_share = player_penalty_shares[player["id"]]
+ base_pen_pts = points_config["penalty_points_per_position"].get(player_pos, 0)
+ pts_penalty = (base_pen_pts * pen_share) * scaling_factor
+
+ # --- APPEARANCE ---
+ pts_app = 2 if xMins_in_match > 60 else (1 if xMins_in_match > 0 else 0)
+
+ # --- BONUS POINTS ---
+ bps_floor = player["baseline_bps_floor_p90"] * scaling_factor
+ bps_mins = 6 if xMins_in_match >= 60 else (3 if xMins_in_match > 0 else 0)
+
+ scaled_goals = proj_goals * scaling_factor
+ scaled_assists = proj_assists * scaling_factor
+ scaled_saves = proj_saves * scaling_factor if is_gk else 0
+ scaled_pen_saves = proj_pen_saves * scaling_factor if is_gk else 0
+ scaled_yc = player["YC_share"] * team_proj_yc * scaling_factor
+ scaled_rc = player["RC_share"] * team_proj_rc * scaling_factor
+
+ bps_goals = scaled_goals * (24 if is_fwd else (18 if is_mid else 12))
+ bps_assists = scaled_assists * 9
+ bps_cs = cs_odds * 12 if (is_gk or is_def) and xMins_in_match >= 60 else 0
+ bps_saves = scaled_saves * 2
+ bps_pen_saves = scaled_pen_saves * 15
+ bps_cards = (scaled_yc * -3) + (scaled_rc * -9)
+
+ total_projected_bps = (
+ bps_floor
+ + bps_mins
+ + bps_goals
+ + bps_assists
+ + bps_cs
+ + bps_saves
+ + bps_pen_saves
+ + bps_cards
+ )
+ pts_bonus = total_projected_bps / 29.4 if not is_gk else 0.0
+
+ # --- FINAL SUM ---
+ total_pts = (
+ pts_goals
+ + pts_assists
+ + pts_cs
+ + pts_conc
+ + pts_yc
+ + pts_rc
+ + pts_saves
+ + pts_pen_save
+ + pts_cbit
+ + pts_cbitr
+ + pts_penalty
+ + pts_app
+ + pts_bonus
+ )
+
+ return {
+ "pts": total_pts,
+ "xG": proj_goals * scaling_factor,
+ "xA": proj_assists * scaling_factor,
+ "CS": cs_odds if xMins_in_match >= 60 else cs_odds * scaling_factor,
+ "cbit": proj_cbit * scaling_factor,
+ "cbitr": proj_cbitr * scaling_factor,
+ }
+
+
+def calculate_all_points(
+ player_df_base,
+ match_df,
+ player_penalty_shares,
+ MINS_SCALING_BONUS,
+ pos_map,
+ teams_dict_1,
+ teams_dict,
+ points_config,
+ effective_xmins_overrides,
+ MINS_THRESHOLD,
+ RAMP_UP_PERIOD,
+ decay_rates,
+ ramp_up_rates,
+ user_player_status_overrides,
+ team_skepticism,
+ effective_availability_multipliers,
+):
+ RAMP_UP_PERIOD = 3
+ player_df = player_df_base.copy()
+
+ final_df_output = pd.DataFrame(
+ {
+ "Pos": player_df["element_type"].map(pos_map),
+ "ID": player_df["id"],
+ "Name": player_df["web_name"],
+ "BV": player_df["now_cost"],
+ "SV": player_df["now_cost"],
+ "Team": player_df["Team"],
+ }
+ )
+
+ continuous_xMins_progression = player_df["baseline_xMins"].copy()
+ has_baseline_xmins_override = getattr(player_df, "attrs", {}).get(
+ "has_baseline_xmins_override", False
+ )
+ all_baseline_overrides = getattr(player_df, "attrs", {}).get(
+ "all_baseline_overrides", {}
+ )
+ unique_gws = sorted(match_df["GW"].unique())
+
+ match_projections_col = {index: {} for index in player_df.index}
+
+ for gw_idx, gw in enumerate(unique_gws):
+ if has_baseline_xmins_override and gw == 1:
+ for index, player in player_df.iterrows():
+ player_id = player["id"]
+ if (
+ player_id in all_baseline_overrides
+ and "baseline_xMins" in all_baseline_overrides[player_id]
+ ):
+ continuous_xMins_progression.loc[index] = all_baseline_overrides[
+ player_id
+ ]["baseline_xMins"]
+
+ gw_calc_df = pd.DataFrame(index=player_df.index)
+ gw_calc_df["team"] = player_df["team"]
+ gw_calc_df["id"] = player_df["id"]
+ gw_calc_df["web_name"] = player_df["web_name"]
+ gw_calc_df["player_name"] = player_df["name"]
+ gw_calc_df["xG_share"] = player_df["xG_share"]
+ gw_calc_df["xA_share"] = player_df["xA_share"]
+ gw_calc_df["baseline_xMins"] = player_df["baseline_xMins"]
+ gw_calc_df["baseline_bps_floor_p90"] = player_df["baseline_bps_floor_p90"]
+ gw_calc_df["base_pts"] = 0.0
+
+ # VECTORIZED XMINS CALCULATION
+ player_ids_array = player_df["id"].values
+ n_players = len(player_ids_array)
+
+ status_list = [
+ user_player_status_overrides.get(pid, {"status": "default"})["status"]
+ for pid in player_ids_array
+ ]
+ weeks_out_list = [
+ user_player_status_overrides.get(pid, {}).get("weeks_out", 0)
+ for pid in player_ids_array
+ ]
+
+ status_array = np.array(status_list, dtype=object)
+ weeks_out_array = np.array(weeks_out_list)
+
+ is_not_starter = status_array == "not_a_starter"
+ is_suspended = status_array == "suspended"
+ is_injured = status_array == "injured"
+ is_default = ~(is_not_starter | is_suspended | is_injured)
+
+ baseline_mins_array = player_df["baseline_xMins"].values
+ prev_continuous_xmins_array = continuous_xMins_progression.values
+
+ calculated_xmins_array = np.zeros(n_players, dtype=float)
+ next_continuous_xmins_array = np.zeros(n_players, dtype=float)
+
+ first_gw = min(unique_gws)
+ is_first_gw = gw == first_gw
+ is_available_first_gw = ~(is_not_starter | is_suspended | is_injured)
+
+ # CASE 1: First GW + Available
+ if is_first_gw:
+ mask_first_available = is_available_first_gw
+ calculated_xmins_array[mask_first_available] = baseline_mins_array[
+ mask_first_available
+ ]
+
+ calculated_xmins_array[is_not_starter] = 0
+
+ # CASE 3: Suspended
+ mask_suspended_during = is_suspended & (gw <= weeks_out_array)
+ mask_suspended_return = is_suspended & (gw == weeks_out_array + 1)
+ mask_suspended_after = is_suspended & (gw > weeks_out_array + 1)
+
+ calculated_xmins_array[mask_suspended_during] = 0
+ calculated_xmins_array[mask_suspended_return] = baseline_mins_array[
+ mask_suspended_return
+ ]
+
+ decay_rate_susp = decay_rates.get("suspended", decay_rates.get("default", 0.99))
+ ramp_rate_susp = ramp_up_rates.get("suspended", ramp_up_rates.get("default", 0))
+
+ mask_susp_decay = mask_suspended_after & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ mask_susp_ramp = mask_suspended_after & (
+ prev_continuous_xmins_array < MINS_THRESHOLD
+ )
+
+ calculated_xmins_array[mask_susp_decay] = (
+ prev_continuous_xmins_array[mask_susp_decay] * decay_rate_susp
+ )
+ calculated_xmins_array[mask_susp_ramp] = np.minimum(
+ prev_continuous_xmins_array[mask_susp_ramp] + ramp_rate_susp, 90
+ )
+
+ # CASE 4: Injured
+ mask_injured_out = is_injured & (gw <= weeks_out_array)
+ calculated_xmins_array[mask_injured_out] = 0
+
+ mask_injured_recovering = is_injured & (gw > weeks_out_array)
+ weeks_since_injury_array = np.maximum(0, gw - weeks_out_array)
+
+ mask_ramp_phase = mask_injured_recovering & (
+ weeks_since_injury_array <= RAMP_UP_PERIOD
+ )
+ calculated_xmins_array[mask_ramp_phase] = (
+ baseline_mins_array[mask_ramp_phase] / RAMP_UP_PERIOD
+ ) * weeks_since_injury_array[mask_ramp_phase]
+
+ mask_post_ramp = mask_injured_recovering & (
+ weeks_since_injury_array > RAMP_UP_PERIOD
+ )
+
+ decay_rate_default = decay_rates.get("default", 0.99)
+ ramp_rate_default = ramp_up_rates.get(
+ "default", ramp_up_rates.get("injured", 0)
+ )
+
+ mask_post_decay = mask_post_ramp & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ mask_post_ramp_up = mask_post_ramp & (
+ prev_continuous_xmins_array < MINS_THRESHOLD
+ )
+
+ calculated_xmins_array[mask_post_decay] = (
+ prev_continuous_xmins_array[mask_post_decay] * decay_rate_default
+ )
+ calculated_xmins_array[mask_post_ramp_up] = np.minimum(
+ prev_continuous_xmins_array[mask_post_ramp_up] + ramp_rate_default, 90
+ )
+
+ # CASE 5: Default/healthy
+ mask_default_calc = is_default & ~(is_first_gw & is_available_first_gw)
+ element_type_array = player_df["element_type"].values
+ is_gk = element_type_array == 1
+
+ mask_gk_default = mask_default_calc & is_gk
+ calculated_xmins_array[mask_gk_default] = prev_continuous_xmins_array[
+ mask_gk_default
+ ]
+
+ mask_outfield_default = mask_default_calc & (~is_gk)
+ mask_outf_decay = mask_outfield_default & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ calculated_xmins_array[mask_outf_decay] = (
+ prev_continuous_xmins_array[mask_outf_decay] * decay_rate_default
+ )
+
+ mask_outf_ramp = (
+ mask_outfield_default
+ & (prev_continuous_xmins_array < MINS_THRESHOLD)
+ & (baseline_mins_array > 0)
+ )
+ calculated_xmins_array[mask_outf_ramp] = np.minimum(
+ prev_continuous_xmins_array[mask_outf_ramp] + ramp_rate_default, 90
+ )
+
+ calculated_xmins_array = np.clip(calculated_xmins_array, 0, 90)
+ next_continuous_xmins_array = calculated_xmins_array.copy()
+
+ # APPLY OVERRIDES AND AVAILABILITY
+ xMins_for_current_gw_display = calculated_xmins_array.copy()
+ for idx in range(n_players):
+ player_id = player_ids_array[idx]
+ availability_mult = effective_availability_multipliers.get(
+ player_id, {}
+ ).get(gw, 1.0)
+ xMins_for_current_gw_display[idx] *= availability_mult
+
+ if (
+ player_id in effective_xmins_overrides
+ and gw in effective_xmins_overrides[player_id]
+ ):
+ xMins_for_current_gw_display[idx] = effective_xmins_overrides[
+ player_id
+ ][gw]
+
+ xMins_for_current_gw_display = pd.Series(
+ xMins_for_current_gw_display, index=player_df.index
+ )
+ next_gw_continuous_xMins = pd.Series(
+ next_continuous_xmins_array, index=player_df.index
+ )
+ gw_calc_df[f"{gw}_xMins"] = xMins_for_current_gw_display
+
+ # STREAMLINED MATCH SCORING LOOP
+ gw_matches = match_df[match_df["GW"] == gw]
+
+ for index, player in player_df.iterrows():
+ player_team_num = player["team"]
+ my_matches = gw_matches[
+ (gw_matches["home_team_num"] == player_team_num)
+ | (gw_matches["away_team_num"] == player_team_num)
+ ]
+
+ if my_matches.empty:
+ gw_calc_df.loc[index, "base_pts"] = 0
+ gw_calc_df.loc[index, f"{gw}_xMins"] = 0
+ gw_calc_df.loc[index, "gw_xG"] = 0.0
+ gw_calc_df.loc[index, "gw_xA"] = 0.0
+ gw_calc_df.loc[index, "gw_CS"] = 0.0
+ gw_calc_df.loc[index, "gw_cbit"] = 0.0
+ gw_calc_df.loc[index, "gw_cbitr"] = 0.0
+ continue
+
+ base_gw_mins = gw_calc_df.loc[index, f"{gw}_xMins"]
+ mins_per_match = (
+ base_gw_mins * 0.97
+ if len(my_matches) > 1 and base_gw_mins > 35
+ else base_gw_mins
+ )
+
+ total_gw_pts = 0
+ total_gw_xg = 0
+ total_gw_xa = 0
+ total_gw_cs = 0
+ total_gw_cbit = 0
+ total_gw_cbitr = 0
+
+ for _, match_row in my_matches.iterrows():
+ stats = calculate_single_match_points(
+ player=player,
+ match_row=match_row,
+ xMins_in_match=mins_per_match,
+ points_config=points_config,
+ player_penalty_shares=player_penalty_shares,
+ is_gk=(player["element_type"] == 1),
+ is_def=(player["element_type"] == 2),
+ is_mid=(player["element_type"] == 3),
+ is_fwd=(player["element_type"] == 4),
+ )
+ total_gw_pts += stats["pts"]
+ total_gw_xg += stats["xG"]
+ total_gw_xa += stats["xA"]
+ total_gw_cs += stats["CS"]
+ total_gw_cbit += stats["cbit"]
+ total_gw_cbitr += stats["cbitr"]
+
+ is_home = player_team_num == match_row["home_team_num"]
+ opp_num = (
+ match_row["away_team_num"]
+ if is_home
+ else match_row["home_team_num"]
+ )
+ match_id = (
+ f"{match_row['home_team_num']}_vs_{match_row['away_team_num']}"
+ )
+
+ match_projections_col[index][match_id] = {
+ "opponent_team_id": int(opp_num),
+ "is_home": bool(is_home),
+ "default_gw": int(gw),
+ "Pts": round(stats["pts"], 3),
+ "xMins": round(mins_per_match, 1),
+ "xG": round(stats["xG"], 3),
+ "xA": round(stats["xA"], 3),
+ "CS": round(stats["CS"], 3),
+ }
+
+ gw_calc_df.loc[index, "base_pts"] = total_gw_pts
+ gw_calc_df.loc[index, "gw_xG"] = total_gw_xg
+ gw_calc_df.loc[index, "gw_xA"] = total_gw_xa
+ gw_calc_df.loc[index, "gw_CS"] = total_gw_cs
+ gw_calc_df.loc[index, "gw_cbit"] = total_gw_cbit
+ gw_calc_df.loc[index, "gw_cbitr"] = total_gw_cbitr
+
+ gw_calc_df = apply_team_skepticism(gw_calc_df, team_skepticism)
+ gw_calc_df["total_pts"] = gw_calc_df["base_pts"]
+
+ final_df_output[f"{gw}_xMins"] = round(gw_calc_df[f"{gw}_xMins"], 0)
+ final_df_output[f"{gw}_Pts"] = round(gw_calc_df["total_pts"], 2)
+ final_df_output[f"{gw}_xG"] = round(gw_calc_df["gw_xG"], 2)
+ final_df_output[f"{gw}_xA"] = round(gw_calc_df["gw_xA"], 2)
+ final_df_output[f"{gw}_CS"] = gw_calc_df["gw_CS"]
+ final_df_output[f"{gw}_cbit"] = gw_calc_df["gw_cbit"]
+ final_df_output[f"{gw}_cbitr"] = gw_calc_df["gw_cbitr"]
+ continuous_xMins_progression = next_gw_continuous_xMins.copy()
+
+ final_df_output["Total Points"] = final_df_output.filter(like="_Pts").sum(axis=1)
+ final_df_output["Average Points"] = round(
+ (final_df_output.filter(like="_Pts").sum(axis=1)) / len(unique_gws), 2
+ )
+ final_df_output["match_projections"] = pd.Series(match_projections_col)
+ return final_df_output
diff --git a/ewmapois_model.csv b/ewmapois_model.csv
new file mode 100644
index 0000000000000000000000000000000000000000..7bcde92dfc16686179cbb1591928077dcfdbe563
--- /dev/null
+++ b/ewmapois_model.csv
@@ -0,0 +1,72 @@
+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
+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,,
+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,,,
+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,,,,,
+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,,,
+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,,,
+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,
+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,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,,,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,
+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,,,,,
+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,,
+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,,,
+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,,,
+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,,,
+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,
+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,,,
+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,,,,,
+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,,,
+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,,,
+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,,
+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,,,
+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,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,
+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,,
+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,,,,,
+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,,
+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,,,
+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,,,
+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,,,,
+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,,,
+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,,,,,
+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,,
+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,,,,
+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,,,
+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,,,
+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,
+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,,,
+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,,,
+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,,,,,
+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,,,,,
+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
+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,,,
+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,,,
+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,,,
+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,
+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,,,,
+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,,,
+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,,,
+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,
+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,,,,,
+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,,,
+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,,,,,
+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,,,,,
+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,,,
+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,
+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,,
+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,,,
+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,,,,,
+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,,,,,
+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,,,
diff --git a/fpl_api.py b/fpl_api.py
new file mode 100644
index 0000000000000000000000000000000000000000..587b7c6aeb6974e484543a9ddd01d7ddb22fe6f3
--- /dev/null
+++ b/fpl_api.py
@@ -0,0 +1,88 @@
+import requests
+
+BASE_URL = "https://fantasy.premierleague.com/api"
+AFCON_GW = 16
+
+
+def calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws):
+ """Exact logic ported from open-fpl-solver dev/solver.py"""
+ n_transfers = {gw: 0 for gw in range(2, next_gw + 2)}
+ for t in transfers:
+ if t["event"] in n_transfers:
+ n_transfers[t["event"]] += 1
+
+ fts = {gw: 0 for gw in range(first_gw + 1, next_gw + 2)}
+ fts[first_gw + 1] = 1
+
+ for i in range(first_gw + 2, next_gw + 1):
+ if i == AFCON_GW:
+ fts[i] = 5
+ continue
+ if (i - 1) in fh_gws or (i - 1) in wc_gws:
+ fts[i] = fts[i - 1]
+ continue
+
+ fts[i] = fts[i - 1] - n_transfers[i - 1]
+ fts[i] = max(fts[i], 0)
+ fts[i] += 1
+ fts[i] = min(fts[i], 5)
+
+ return fts.get(next_gw, 1)
+
+
+def get_fpl_team_data(team_id: int):
+ print(f"Executing strict open-fpl-solver logic for Team ID: {team_id}...")
+
+ static = requests.get(f"{BASE_URL}/bootstrap-static/").json()
+ element_to_type = {x["id"]: x["element_type"] for x in static["elements"]}
+ next_gw = next(x["id"] for x in static["events"] if x["is_next"])
+ start_prices = {
+ x["id"]: x["now_cost"] - x["cost_change_start"] for x in static["elements"]
+ }
+
+ transfers = requests.get(f"{BASE_URL}/entry/{team_id}/transfers/").json()[::-1]
+ history = requests.get(f"{BASE_URL}/entry/{team_id}/history/").json()
+
+ chips = history["chips"]
+ fh_gws = [x["event"] for x in chips if x["name"] == "freehit"]
+ wc_gws = [x["event"] for x in chips if x["name"] == "wildcard"]
+
+ first_gw = history["current"][0]["event"]
+ first_gw_data = requests.get(
+ f"{BASE_URL}/entry/{team_id}/event/{first_gw}/picks/"
+ ).json()
+
+ # Calculate exact purchase prices and ITB
+ squad = {x["element"]: start_prices[x["element"]] for x in first_gw_data["picks"]}
+ itb = 1000 - sum(squad.values())
+
+ for t in transfers:
+ if t["event"] in fh_gws:
+ continue
+ itb += t["element_out_cost"]
+ itb -= t["element_in_cost"]
+ if t["element_in"]:
+ squad[t["element_in"]] = t["element_in_cost"]
+ if t["element_out"] and t["element_out"] in squad:
+ del squad[t["element_out"]]
+
+ fts = calculate_fts(transfers, first_gw, next_gw, fh_gws, wc_gws)
+
+ picks = []
+ for player_id, purchase_price in squad.items():
+ now_cost = next(
+ x["now_cost"] for x in static["elements"] if x["id"] == player_id
+ )
+ diff = now_cost - purchase_price
+ selling_price = purchase_price + (diff // 2) if diff > 0 else now_cost
+
+ picks.append(
+ {
+ "id": player_id,
+ "purchase_price": purchase_price / 10.0,
+ "selling_price": selling_price / 10.0,
+ "now_cost": now_cost / 10.0,
+ }
+ )
+
+ return {"in_the_bank": itb / 10.0, "free_transfers": fts, "squad": picks}
diff --git a/fpl_streamlit_app.py b/fpl_streamlit_app.py
new file mode 100644
index 0000000000000000000000000000000000000000..c1e028e0b83fc30dd188473e78f6cd6c9a952e20
--- /dev/null
+++ b/fpl_streamlit_app.py
@@ -0,0 +1,4291 @@
+import streamlit as st
+import pandas as pd
+import numpy as np
+import json
+import math
+import requests
+import os
+from scipy.stats import nbinom
+from PIL import Image
+import altair as alt
+import base64
+import sklearn # noqa: F401
+from sklearn.metrics import (
+ brier_score_loss,
+ mean_squared_error,
+ mean_absolute_error,
+ r2_score,
+ log_loss,
+)
+import plotly.express as px
+import plotly.graph_objects as go
+import hashlib
+
+# --- Configuration for Admin Access ---
+ADMIN_PASSWORD = (
+ "Monkeyrocks11$$" # <<< IMPORTANT: CHANGE THIS TO A STRONG PASSWORD IN REAL USE!
+)
+
+GROUP_FILE = "player_groups.json"
+
+TEAMS_DICT = {
+ "Arsenal": 1,
+ "Aston Villa": 2,
+ "Burnley": 3,
+ "AFC Bournemouth": 4,
+ "Brentford": 5,
+ "Brighton and Hove Albion": 6,
+ "Chelsea": 7,
+ "Crystal Palace": 8,
+ "Everton": 9,
+ "Fulham": 10,
+ "Leeds United": 11,
+ "Liverpool": 12,
+ "Manchester City": 13,
+ "Manchester United": 14,
+ "Newcastle United": 15,
+ "Nottingham Forest": 16,
+ "Sunderland": 17,
+ "Tottenham Hotspur": 18,
+ "West Ham United": 19,
+ "Wolverhampton Wanderers": 20,
+}
+
+TEAMS_DICT_REVERSE = {
+ 1: "Arsenal",
+ 2: "Aston Villa",
+ 3: "Burnley",
+ 4: "Bournemouth",
+ 5: "Brentford",
+ 6: "Brighton",
+ 7: "Chelsea",
+ 8: "Crystal Palace",
+ 9: "Everton",
+ 10: "Fulham",
+ 11: "Leeds",
+ 12: "Liverpool",
+ 13: "Man City",
+ 14: "Man Utd",
+ 15: "Newcastle",
+ 16: "Nott'm Forest",
+ 17: "Sunderland",
+ 18: "Spurs",
+ 19: "West Ham",
+ 20: "Wolves",
+}
+
+
+@st.cache_data(ttl=3600)
+def get_base64_of_bin_file(bin_file):
+ with open(bin_file, "rb") as f:
+ data = f.read()
+ return base64.b64encode(data).decode()
+
+
+def set_bg_hack(main_bg, opacity=0.15):
+ """
+ A function to unpack an image from root folder and set as bg.
+
+ Parameters
+ ----------
+ main_bg : str
+ The file path to the image.
+ opacity : float
+ The opacity of the overlay (0.0 to 1.0).
+ 0.0 = fully visible image, 1.0 = solid color cover.
+ """
+ main_bg_ext = "png"
+
+ # Read and encode the image
+
+ b64_encoded = get_base64_of_bin_file(main_bg)
+
+ # CSS to inject
+ # The linear-gradient overlays a semi-transparent black layer
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+def sidebar_bg(side_bg):
+ side_bg_ext = "png"
+
+ with open(side_bg, "rb") as f:
+ img_data = f.read()
+ b64_encoded = base64.b64encode(img_data).decode()
+
+ st.markdown(
+ f"""
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+side_bg = "luigiside.png"
+sidebar_bg(side_bg)
+set_bg_hack("luigismansion.jpg")
+
+st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+)
+
+
+def set_custom_font(font_path):
+ with open(font_path, "rb") as f:
+ data = f.read()
+ b64 = base64.b64encode(data).decode()
+
+ font_css = f"""
+
+ """
+ st.markdown(font_css, unsafe_allow_html=True)
+
+
+set_custom_font("luigifont.ttf")
+
+
+def load_player_groups():
+ if os.path.exists(GROUP_FILE):
+ try:
+ with open(GROUP_FILE, "r") as f:
+ return json.load(f)
+ except: # noqa: E722
+ return {}
+ return {}
+
+
+def save_player_groups(groups):
+ with open(GROUP_FILE, "w") as f:
+ json.dump(groups, f, indent=4)
+
+
+if "player_groups" not in st.session_state:
+ st.session_state.player_groups = load_player_groups()
+
+# --- Initialize all session state variables at the top level ---
+# This ensures they are always present, even on reruns.
+st.session_state.setdefault("initialized", False)
+st.session_state.setdefault("finalized_df", None)
+st.session_state.setdefault("match_df", None)
+# Player penalty shares: Initialized with a default dict, will be overridden by loaded data
+st.session_state.setdefault("player_penalty_shares", {})
+st.session_state.setdefault("admin_persistent_availability_multipliers", {})
+st.session_state.setdefault("user_availability_multipliers", {}) # Session-only
+st.session_state.setdefault("selected_availability_player", None)
+st.session_state.setdefault("selected_availability_player_non_admin", None)
+st.session_state.setdefault("MINS_THRESHOLD", 30)
+# MINS_SCALING_BONUS is explicitly 0.0, as confirmed previously.
+st.session_state.setdefault("MINS_SCALING_BONUS", 0.0)
+st.session_state.setdefault("pos_map", {})
+st.session_state.setdefault("teams_dict_1", {})
+st.session_state.setdefault("teams_dict", {})
+# FIX: Adjusted points_config to remove CBIT/CBITR related multipliers, as they are now tiered.
+st.session_state.setdefault(
+ "points_config",
+ {
+ "goal": {1: 10, 2: 6, 3: 5, 4: 4},
+ "assist": 3,
+ "clean_sheet": {1: 4, 2: 4, 3: 1, 4: 0},
+ "saves_per_3": 1,
+ # "cbit_per_10": 2, # Removed: now handled by tiered logic
+ # "cbitr_per_12": 2, # Removed: now handled by tiered logic
+ # "cbit_cbitr_multiplier": 0.45, # Removed: now handled by tiered logic
+ "penalty_points_per_position": {2: 0.8, 3: 0.6, 4: 0.5},
+ },
+)
+st.session_state.setdefault(
+ "user_xmins_overrides", {}
+) # Session-only overrides for ALL users
+st.session_state.setdefault(
+ "admin_persistent_xmins_overrides", {}
+) # Admin-specific, saved to file
+st.session_state.setdefault("user_baseline_overrides", {}) # Crucial: Initialized here
+st.session_state.setdefault(
+ "user_player_status_overrides", {}
+) # Crucial: Initialized here
+st.session_state.setdefault("output_df", None)
+st.session_state.setdefault("team_baselines", None)
+st.session_state.setdefault("admin_persistent_share_overrides", {})
+
+# Default decay and ramp-up rates (will be loaded/overridden from rates_config.json)
+# These provide a fallback if rates_config.json doesn't exist or loading fails.
+st.session_state.setdefault(
+ "decay_rates",
+ {
+ "default": 0.99,
+ "suspended": 0.99,
+ "injured_decay": 0.99,
+ "rotational_risk": 0.95,
+ },
+)
+st.session_state.setdefault(
+ "ramp_up_rates",
+ {
+ "default": 3,
+ "injured": 9,
+ "suspended": 3,
+ "starter": 0,
+ "rotational_risk": 2,
+ },
+)
+st.session_state.setdefault("team_skepticism", {})
+st.session_state.setdefault("RAMP_UP_PERIOD", 3)
+
+# Initialize session state for selected players in dropdowns
+st.session_state.setdefault("selected_xm_player", None)
+st.session_state.setdefault("selected_status_player", None)
+st.session_state.setdefault("selected_baseline_player", None)
+st.session_state.setdefault("selected_penalty_player", None)
+
+# Admin login status
+st.session_state.setdefault("is_admin_logged_in", False)
+
+
+@st.cache_data(ttl=300, show_spinner="Loading FPL data...")
+def fetch_fpl_data_cached():
+ """
+ Cached version of FPL API fetch - reduces load time by 5-10 seconds
+ TTL of 300 seconds (5 minutes) keeps data fresh enough
+ """
+ try:
+ url = "https://fantasy.premierleague.com/api/bootstrap-static/"
+ response = requests.get(url, timeout=10)
+ response.raise_for_status()
+ return response.json()
+ except Exception as e:
+ st.error(f"Failed to fetch FPL data: {e}")
+ return None
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def load_team_ratings_cached():
+ """Cache team ratings CSV - loaded on Tab 3"""
+ df = pd.read_csv("team_ratings_dual_speed.csv")
+ return df
+
+
+@st.cache_data(ttl=3600, show_spinner=False)
+def load_fixture_projections_cached():
+ """Cache fixture projections CSV - loaded on Tab 4"""
+ df = pd.read_csv("ewmapois_model.csv")
+ df.columns = df.columns.str.strip()
+ return df
+
+
+# --- Persistence Functions ---
+def make_json_serializable(obj):
+ """Convert numpy/pandas types to JSON serializable types"""
+ if hasattr(obj, "item"): # numpy scalar
+ return obj.item()
+ elif hasattr(obj, "tolist"): # numpy array
+ return obj.tolist()
+ elif isinstance(obj, dict):
+ return {str(k): make_json_serializable(v) for k, v in obj.items()}
+ elif isinstance(obj, (list, tuple)):
+ return [make_json_serializable(item) for item in obj]
+ elif isinstance(obj, (int, float, str, bool)) or obj is None:
+ return obj
+ else:
+ # For any other type, try to convert to string
+ return str(obj)
+
+
+def save_admin_overrides():
+ """
+ Save ONLY admin-controlled overrides (baselines, status, penalties, global rates,
+ and admin-persistent weekly xMins) to JSON files.
+ """
+ try:
+ # Save baseline overrides
+ baseline_data = make_json_serializable(st.session_state.user_baseline_overrides)
+ with open("user_baseline_overrides.json", "w", encoding="utf-8") as f:
+ json.dump(baseline_data, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Save player status overrides
+ status_data = make_json_serializable(
+ st.session_state.user_player_status_overrides
+ )
+ with open("user_player_status_overrides.json", "w", encoding="utf-8") as f:
+ json.dump(status_data, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Save player penalty shares
+ penalty_shares_data = make_json_serializable(
+ st.session_state.player_penalty_shares
+ )
+ with open("player_penalty_shares.json", "w", encoding="utf-8") as f:
+ json.dump(penalty_shares_data, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Save rates config
+ rates_config = {
+ "decay_rates": make_json_serializable(st.session_state.decay_rates),
+ "ramp_up_rates": make_json_serializable(st.session_state.ramp_up_rates),
+ "RAMP_UP_PERIOD": make_json_serializable(st.session_state.RAMP_UP_PERIOD),
+ "MINS_THRESHOLD": make_json_serializable(st.session_state.MINS_THRESHOLD),
+ }
+ with open("rates_config.json", "w", encoding="utf-8") as f:
+ json.dump(rates_config, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Save admin-persistent xMins overrides
+ admin_xmins_data = make_json_serializable(
+ st.session_state.admin_persistent_xmins_overrides
+ )
+ with open("admin_persistent_xmins_overrides.json", "w", encoding="utf-8") as f:
+ json.dump(admin_xmins_data, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+
+ # Save admin-persistent availability multipliers
+ availability_data = make_json_serializable(
+ st.session_state.admin_persistent_availability_multipliers
+ )
+ with open(
+ "admin_persistent_availability_multipliers.json", "w", encoding="utf-8"
+ ) as f:
+ json.dump(availability_data, f, indent=4, ensure_ascii=False)
+ f.flush()
+ os.fsync(f.fileno())
+ share_data = make_json_serializable(
+ st.session_state.admin_persistent_share_overrides
+ )
+ try:
+ with open(
+ "admin_persistent_share_overrides.json", "w", encoding="utf-8"
+ ) as f:
+ json.dump(share_data, f, indent=4, ensure_ascii=False)
+ except Exception as e:
+ st.error(f"Error saving share overrides: {e}")
+
+ return True
+
+ except Exception as e:
+ st.error(f"Error saving admin overrides: {e}")
+ return False
+
+
+def load_admin_overrides():
+ """
+ Load ONLY admin-controlled overrides from JSON files.
+ user_xmins_overrides (session-only) are NOT loaded from file.
+ """
+ try:
+ # Load baseline overrides
+ if (
+ os.path.exists("user_baseline_overrides.json")
+ and os.path.getsize("user_baseline_overrides.json") > 0
+ ):
+ with open("user_baseline_overrides.json", "r", encoding="utf-8") as f:
+ loaded_baselines = json.load(f)
+ # Convert string keys back to integers
+ st.session_state.user_baseline_overrides = {
+ int(pid): stat_dict for pid, stat_dict in loaded_baselines.items()
+ }
+ else:
+ st.session_state.user_baseline_overrides = {}
+
+ # Load player status overrides
+ if (
+ os.path.exists("user_player_status_overrides.json")
+ and os.path.getsize("user_player_status_overrides.json") > 0
+ ):
+ with open("user_player_status_overrides.json", "r", encoding="utf-8") as f:
+ loaded_status = json.load(f)
+ st.session_state.user_player_status_overrides = {
+ int(pid): status_dict for pid, status_dict in loaded_status.items()
+ }
+ else:
+ st.session_state.user_player_status_overrides = {}
+
+ # Load player penalty shares
+ if (
+ os.path.exists("player_penalty_shares.json")
+ and os.path.getsize("player_penalty_shares.json") > 0
+ ):
+ with open("player_penalty_shares.json", "r", encoding="utf-8") as f:
+ loaded_penalty_shares = json.load(f)
+ st.session_state.player_penalty_shares = {
+ int(pid): share for pid, share in loaded_penalty_shares.items()
+ }
+ else:
+ # Default penalty shares if file not found
+ st.session_state.player_penalty_shares = {
+ 16: 0.65,
+ 17: 0.15,
+ 30: 0.05,
+ 666: 0.3,
+ 48: 0.4,
+ 64: 0.7,
+ 81: 0.9,
+ 97: 0.25,
+ 136: 0.8,
+ 121: 0.09,
+ 178: 0.8,
+ 158: 0.05,
+ 202: 0.25,
+ 215: 0.6,
+ 216: 0.02,
+ 235: 0.9,
+ 249: 0.1,
+ 266: 0.6,
+ 267: 0.04,
+ 283: 0.4,
+ 299: 0.85,
+ 311: 0.1,
+ 310: 0.1,
+ 337: 0.6,
+ 327: 0.55,
+ 343: 0.4,
+ 362: 0.7,
+ 381: 0.95,
+ 382: 0.1,
+ 386: 0.05,
+ 430: 0.95,
+ 413: 0.15,
+ 449: 0.9,
+ 119: 0.1,
+ 450: 0.05,
+ 499: 0.85,
+ 485: 0.2,
+ 474: 0.02,
+ 525: 0.85,
+ 515: 0.25,
+ 596: 0.9,
+ 612: 0.8,
+ 624: 0.25,
+ 625: 0.04,
+ 647: 0.1,
+ 654: 0.85,
+ }
+
+ # Load rates config
+ if (
+ os.path.exists("rates_config.json")
+ and os.path.getsize("rates_config.json") > 0
+ ):
+ with open("rates_config.json", "r", encoding="utf-8") as f:
+ rates_config = json.load(f)
+ st.session_state.decay_rates = rates_config.get(
+ "decay_rates", st.session_state.decay_rates
+ )
+ st.session_state.ramp_up_rates = rates_config.get(
+ "ramp_up_rates", st.session_state.ramp_up_rates
+ )
+ st.session_state.RAMP_UP_PERIOD = rates_config.get(
+ "RAMP_UP_PERIOD", st.session_state.RAMP_UP_PERIOD
+ )
+ st.session_state.MINS_THRESHOLD = rates_config.get(
+ "MINS_THRESHOLD", st.session_state.MINS_THRESHOLD
+ )
+
+ # Load admin-persistent xMins overrides
+ if (
+ os.path.exists("admin_persistent_xmins_overrides.json")
+ and os.path.getsize("admin_persistent_xmins_overrides.json") > 0
+ ):
+ with open(
+ "admin_persistent_xmins_overrides.json", "r", encoding="utf-8"
+ ) as f:
+ loaded_xmins = json.load(f)
+ st.session_state.admin_persistent_xmins_overrides = {
+ int(pid): {int(gw): val for gw, val in gw_dict.items()}
+ for pid, gw_dict in loaded_xmins.items()
+ }
+ else:
+ st.session_state.admin_persistent_xmins_overrides = {}
+
+ if (
+ os.path.exists("admin_persistent_availability_multipliers.json")
+ and os.path.getsize("admin_persistent_availability_multipliers.json") > 0
+ ):
+ with open(
+ "admin_persistent_availability_multipliers.json", "r", encoding="utf-8"
+ ) as f:
+ loaded_availability = json.load(f)
+ st.session_state.admin_persistent_availability_multipliers = {
+ int(pid): {int(gw): val for gw, val in gw_dict.items()}
+ for pid, gw_dict in loaded_availability.items()
+ }
+ else:
+ st.session_state.admin_persistent_availability_multipliers = {}
+
+ # Load share overrides
+ try:
+ if os.path.exists("admin_persistent_share_overrides.json"):
+ with open(
+ "admin_persistent_share_overrides.json", "r", encoding="utf-8"
+ ) as f:
+ st.session_state.admin_persistent_share_overrides = json.load(f)
+ else:
+ st.session_state.admin_persistent_share_overrides = {}
+ except Exception as e:
+ st.error(f"Error loading share overrides: {e}")
+ st.session_state.admin_persistent_share_overrides = {}
+
+ return True
+
+ except Exception as e:
+ st.error(f"Error loading admin overrides: {e}")
+ return False
+
+
+# --- Data Loading and Initial Processing Functions ---
+
+
+# ORIGINAL FUNCTION: merge_player_baseline_stats
+@st.cache_data(ttl=3600) # Cache for 24 hours
+def load_baseline_stats_cached(gk_path, outfield_path):
+ """Cache the baseline stats CSVs"""
+ gk_stats_df = pd.read_csv(gk_path)
+ outfield_stats_df = pd.read_csv(outfield_path)
+
+ # Strip columns here too
+ gk_stats_df.columns = gk_stats_df.columns.str.strip()
+ outfield_stats_df.columns = outfield_stats_df.columns.str.strip()
+ gk_stats_df["player_name"] = gk_stats_df["player_name"].str.strip()
+ outfield_stats_df["player_name"] = outfield_stats_df["player_name"].str.strip()
+
+ return gk_stats_df, outfield_stats_df
+
+
+def merge_player_baseline_stats(
+ df, gk_stats_csv_path, outfield_stats_csv_path, gk_element_type=1
+):
+ main_df = df.copy()
+
+ try:
+ gk_stats_df, outfield_stats_df = load_baseline_stats_cached(
+ gk_stats_csv_path, outfield_stats_csv_path
+ )
+ except FileNotFoundError:
+ st.error(
+ f"Error: {gk_stats_csv_path} not found. Please ensure it's in the same directory as the app."
+ )
+ return None # Indicate failure
+ except Exception as e:
+ st.error(f"Error loading {gk_stats_csv_path}: {e}")
+ return None
+
+ try:
+ outfield_stats_df = pd.read_csv(outfield_stats_csv_path)
+ except FileNotFoundError:
+ st.error(
+ f"Error: {outfield_stats_csv_path} not found. Please ensure it's in the same directory as the app."
+ )
+ return None # Indicate failure
+ except Exception as e:
+ st.error(f"Error loading {outfield_stats_csv_path}: {e}")
+ return None
+
+ main_df.columns = main_df.columns.str.strip()
+
+ main_df["name"] = main_df["name"].str.strip()
+
+ gk_mask = main_df["element_type"] == gk_element_type
+ gk_players = main_df[gk_mask].copy()
+ outfield_players = main_df[~gk_mask].copy()
+
+ gk_merged = gk_players.merge(
+ gk_stats_df, left_on="name", right_on="player_name", how="left"
+ )
+ outfield_merged = outfield_players.merge(
+ outfield_stats_df, left_on="name", right_on="player_name", how="left"
+ )
+
+ gk_baseline_cols = [
+ col for col in gk_stats_df.columns if col.startswith("baseline_")
+ ]
+ outfield_baseline_cols = [
+ col for col in outfield_stats_df.columns if col.startswith("baseline_")
+ ]
+
+ for col in outfield_baseline_cols:
+ if col not in gk_merged.columns:
+ gk_merged[col] = np.nan
+ for col in gk_baseline_cols:
+ if col not in outfield_merged.columns:
+ outfield_merged[col] = np.nan
+
+ if "player_name" in gk_merged.columns:
+ gk_merged = gk_merged.drop("player_name", axis=1)
+ if "player_name" in outfield_merged.columns:
+ outfield_merged = outfield_merged.drop("player_name", axis=1)
+
+ final_df = pd.concat([gk_merged, outfield_merged], ignore_index=True)
+ final_df = final_df.sort_values("id").reset_index(drop=True)
+
+ # Fill NaNs specifically for baseline columns with 0.
+ all_baseline_cols_combined = list(set(gk_baseline_cols + outfield_baseline_cols))
+ for col in all_baseline_cols_combined:
+ if col in final_df.columns:
+ final_df[col] = final_df[col].fillna(0)
+
+ return final_df
+
+
+@st.cache_data(ttl=3600)
+def load_data_and_setup_initial_df():
+ # Load or create dummy players.csv
+ r = fetch_fpl_data_cached()
+ players = pd.DataFrame(r["elements"])
+ players["name"] = players["first_name"] + " " + players["second_name"]
+ # Removed 'chance_of_playing_next_round' and 'chance_of_playing_this_round' from drops
+ players = players.drop(
+ columns=[
+ "can_transact",
+ "can_select",
+ "cost_change_event",
+ "cost_change_event_fall",
+ "cost_change_start",
+ "cost_change_start_fall",
+ "dreamteam_count",
+ "ep_next",
+ "ep_this",
+ "form",
+ "in_dreamteam",
+ "news_added",
+ "photo",
+ "removed",
+ "special",
+ "transfers_in",
+ "transfers_in_event",
+ "transfers_out",
+ "transfers_out_event",
+ "value_form",
+ "value_season",
+ "region",
+ "team_join_date",
+ "birth_date",
+ "now_cost_rank",
+ "now_cost_rank_type",
+ "form_rank",
+ "form_rank_type",
+ "points_per_game_rank",
+ "points_per_game_rank_type",
+ "selected_rank",
+ "selected_rank_type",
+ "selected_by_percent",
+ "code",
+ "penalties_text",
+ "has_temporary_code",
+ "first_name",
+ "second_name",
+ ]
+ )
+ columns_order = [
+ "id",
+ "name",
+ "web_name",
+ "element_type",
+ "now_cost",
+ "team",
+ "chance_of_playing_this_round", # Keep this for info, but not directly for decay
+ "news",
+ ]
+ players = players[columns_order]
+ players["now_cost"] = players["now_cost"] / 10
+ # Load or create dummy rename.json
+ try:
+ with open("rename.json", "r", encoding="utf-8") as file:
+ rename_dict = json.load(file)
+ except FileNotFoundError:
+ st.error(
+ "Error: rename.json not found. Please ensure it's in the same directory as the app."
+ )
+ return None
+ except Exception as e:
+ st.error(f"Error loading rename.json: {e}")
+ return None
+
+ players["name"] = players["name"].replace(rename_dict)
+
+ # Merge player baseline statistics
+ finalized_df = merge_player_baseline_stats(
+ players,
+ "statistical_weighted_baselines_gk.csv",
+ "statistical_weighted_baselines.csv",
+ gk_element_type=1,
+ )
+ if finalized_df is None: # Propagate error if merge_player_baseline_stats failed
+ return None
+
+ # Calculate Avg_BPS
+ finalized_df["Avg_BPS"] = 0.0
+ finalized_df.loc[finalized_df["element_type"] == 1, "Avg_BPS"] = finalized_df[
+ "baseline_gk_bps_p90"
+ ].astype(float)
+ finalized_df.loc[finalized_df["element_type"] == 2, "Avg_BPS"] = (
+ finalized_df["baseline_Neutral_BPS_p90"] + finalized_df["baseline_Def_BPS_p90"]
+ )
+ finalized_df.loc[finalized_df["element_type"] == 3, "Avg_BPS"] = (
+ finalized_df["baseline_Neutral_BPS_p90"] + finalized_df["baseline_Mid_BPS_p90"]
+ )
+ finalized_df.loc[finalized_df["element_type"] == 4, "Avg_BPS"] = (
+ finalized_df["baseline_Neutral_BPS_p90"] + finalized_df["baseline_Fwd_BPS_p90"]
+ )
+
+ return finalized_df
+
+
+@st.cache_data(ttl=3600) # Cache for 24 hours
+def load_team_baselines_cached():
+ """Cache the team baselines Excel file"""
+ team_baselines = pd.read_excel("team_totals.xlsx", sheet_name="Sheet2")
+ return team_baselines
+
+
+@st.cache_data
+def load_data_and_setup_initial_df_2(finalized_df):
+ teams_dict = TEAMS_DICT
+ teams_dict_1 = TEAMS_DICT_REVERSE
+
+ # Load team_totals.xlsx from file
+ try:
+ team_baselines = load_team_baselines_cached()
+ team_baselines["Teams"] = team_baselines["Teams"].replace(teams_dict)
+ except FileNotFoundError:
+ st.error(
+ "Error: team_totals.xlsx not found. Please ensure it's in the same directory as the app."
+ )
+ return None, None, None, None # Indicate failure
+ except Exception as e:
+ st.error(f"Error loading team_totals.xlsx: {e}")
+ return None, None, None, None
+
+ # Map team stats to players (these are for calculating shares later)
+ finalized_df["Team_xG"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["xG"].to_dict()
+ )
+ finalized_df["Team_xA"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["xA"].to_dict()
+ )
+ finalized_df["Team_xCBIT"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["CBIT"].to_dict()
+ )
+ finalized_df["Team_xCBITR"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["CBITR"].to_dict()
+ )
+ finalized_df["Team_YC"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["YC"].to_dict()
+ )
+ # FIX: Changed 'df' to 'finalized_df' in the next 6 lines
+ finalized_df["Team_RC"] = finalized_df["team"].map(
+ team_baselines.set_index("Teams")["RC"].to_dict()
+ )
+
+ # Load ewmapois_model.csv (match data) from file
+ try:
+ match_df = load_fixture_projections_cached()
+ except FileNotFoundError:
+ st.error(
+ "Error: ewmapois_model.csv not found. Please ensure it's in the same directory as the app."
+ )
+ return None, None, None, None # Indicate failure
+ except Exception as e:
+ st.error(f"Error loading ewmapois_model.csv: {e}")
+ return None, None, None, None
+
+ # Map team names to numbers for match_df
+ match_df["home_team_num"] = match_df["home_team"].map(teams_dict)
+ match_df["away_team_num"] = match_df["away_team"].map(teams_dict)
+ # Ensure 'Team' column in finalized_df is string names, not IDs
+ finalized_df["Team"] = finalized_df["team"].map(teams_dict_1)
+
+ # Recalculate share percentages, handling division by zero for team totals
+ # Using .replace(0, np.nan) to prevent ZeroDivisionError, then fill NaNs
+ finalized_df["xG_share"] = finalized_df["baseline_xG_p90"] / finalized_df[
+ "Team_xG"
+ ].replace(0, np.nan)
+ finalized_df["xA_share"] = finalized_df["baseline_xA_p90"] / finalized_df[
+ "Team_xA"
+ ].replace(0, np.nan)
+ finalized_df["xCBIT_share"] = finalized_df["baseline_CBIT_p90"] / finalized_df[
+ "Team_xCBIT"
+ ].replace(0, np.nan)
+ finalized_df["xCBITR_share"] = finalized_df["baseline_CBITR_p90"] / finalized_df[
+ "Team_xCBITR"
+ ].replace(0, np.nan)
+ finalized_df["YC_share"] = finalized_df["baseline_yc_p90"] / finalized_df[
+ "Team_YC"
+ ].replace(0, np.nan)
+ finalized_df["RC_share"] = finalized_df["baseline_rc_p90"] / finalized_df[
+ "Team_RC"
+ ].replace(0, np.nan)
+
+ # Fill NaNs resulting from division by zero with 0 or a reasonable default
+ finalized_df.fillna(0, inplace=True)
+
+ # Recalculate shares after initial data load
+ # This call was moved after the relevant finalized_df share calculations above
+ # to ensure finalized_df already has the Team_xG, Team_xA, etc. columns.
+ # The recalculate_player_shares function works on a copy, so it's safe.
+ finalized_df = recalculate_player_shares(finalized_df, team_baselines)
+
+ st.session_state.finalized_df = finalized_df.copy()
+ return finalized_df, match_df, teams_dict, teams_dict_1
+
+
+def poisson_probability_of_conceding_2_or_more_goals(lambd):
+ """Calculates the probability of conceding 2 or more goals using Poisson distribution."""
+ p_0 = math.exp(-lambd)
+ p_1 = lambd * math.exp(-lambd)
+ return 1 - p_0 - p_1
+
+
+def poisson_pmf(k, lambd):
+ """Calculates the Poisson Probability Mass Function P(X=k)."""
+ if k < 0:
+ return 0.0
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
+ return 1.0 if k == 0 else 0.0
+ return (lambd**k * math.exp(-lambd)) / math.factorial(k)
+
+
+def neg_binom_probability_of_value(expected_mean, value, dispersion=1.0):
+ """
+ Calculates the exact probability (PMF) of getting exactly 'value' events.
+ Used for: Saves, Goals, Assists.
+ """
+ if expected_mean <= 0:
+ return 0.0
+ if dispersion <= 1.0: # Fallback to Poisson if no dispersion
+ return poisson_pmf(value, expected_mean)
+
+ # Convert Mean + Dispersion to n, p
+ p = 1 / dispersion
+ n = (expected_mean * p) / (1 - p)
+
+ return nbinom.pmf(value, n, p)
+
+
+def neg_binom_probability_at_least(expected_mean, threshold, dispersion=1.0):
+ """
+ Calculates probability of getting 'threshold' OR MORE events.
+ Used for: DefCons (CBIT), Recoveries.
+ """
+ if expected_mean <= 0:
+ return 0.0
+ if dispersion <= 1.0:
+ # Use existing Poisson logic if dispersion is low
+ return 1 - poisson_cdf(threshold - 1, expected_mean)
+
+ p = 1 / dispersion
+ n = (expected_mean * p) / (1 - p)
+
+ # Probability of X >= threshold is (1 - CDF(threshold - 1))
+ return 1 - nbinom.cdf(threshold - 1, n, p)
+
+
+def calculate_expected_conceded_points(lambd):
+ """
+ Calculates the expected fantasy points from goals conceded based on a
+ -1 point penalty for every 2 goals.
+ """
+ total_expected_points = 0
+ # We check up to 10 goals, which is a safe upper limit for a single match.
+ max_goals_to_check = 10
+
+ for k in range(max_goals_to_check + 1):
+ # Calculate the probability of conceding exactly k goals
+ prob_k = poisson_pmf(k=k, lambd=lambd)
+
+ # Determine the fantasy points for this outcome
+ # Integer division (//) is perfect for this rule.
+ # 0//2=0, 1//2=0, 2//2=1, 3//2=1, 4//2=2, etc.
+ points_for_k_goals = -(k // 2)
+
+ # Add the weighted value (probability * points) to the total
+ total_expected_points += prob_k * points_for_k_goals
+
+ return total_expected_points
+
+
+def poisson_cdf(k, lambd):
+ """Calculates the Poisson Cumulative Distribution Function P(X<=k)."""
+ if k < 0:
+ return 0.0
+ if lambd < 1e-9: # Treat very small lambda as zero for stability
+ return 1.0 if k >= 0 else 0.0
+ return sum(poisson_pmf(i, lambd) for i in range(math.floor(k) + 1))
+
+
+def recalculate_player_shares(finalized_df_copy, team_baselines):
+ """
+ Recalculates player-specific shares of team statistics.
+ This function should be called whenever baseline stats are updated.
+ """
+ df = finalized_df_copy.copy() # Work on a copy
+
+ # Ensure team_baselines are correctly mapped to player df
+ teams_dict = TEAMS_DICT
+ # Ensure team_baselines has 'Teams' mapped to numbers as it is used for mapping
+ team_baselines_mapped = team_baselines.copy()
+ team_baselines_mapped["Teams"] = team_baselines_mapped["Teams"].replace(teams_dict)
+
+ # Map team stats to players (these are for calculating shares later)
+ df["Team_xG"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["xG"].to_dict()
+ )
+ df["Team_xA"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["xA"].to_dict()
+ )
+ df["Team_xCBIT"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["CBIT"].to_dict()
+ )
+ df["Team_xCBITR"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["CBITR"].to_dict()
+ )
+ df["Team_YC"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["YC"].to_dict()
+ )
+ df["Team_RC"] = df["team"].map(
+ team_baselines_mapped.set_index("Teams")["RC"].to_dict()
+ )
+
+ # Calculate share percentages, handling division by zero for team totals
+ # Using .replace(0, np.nan) to prevent ZeroDivisionError, then fill NaNs
+ df["xG_share"] = df["baseline_xG_p90"] / df["Team_xG"].replace(0, np.nan)
+ df["xA_share"] = df["baseline_xA_p90"] / df["Team_xA"].replace(0, np.nan)
+ df["xCBIT_share"] = df["baseline_CBIT_p90"] / df["Team_xCBIT"].replace(0, np.nan)
+ df["xCBITR_share"] = df["baseline_CBITR_p90"] / df["Team_xCBITR"].replace(0, np.nan)
+ df["YC_share"] = df["baseline_yc_p90"] / df["Team_YC"].replace(0, np.nan)
+ df["RC_share"] = df["baseline_rc_p90"] / df["Team_RC"].replace(0, np.nan)
+
+ # Fill NaNs resulting from division by zero with 0 or a reasonable default
+ df.fillna(0, inplace=True) # Ensure no NaNs from division by zero cause issues
+
+ return df
+
+
+def apply_team_skepticism(df, skepticism_factors):
+ """
+ Applies a skepticism multiplier to a player's base points based on their team.
+ """
+ if not skepticism_factors:
+ return df
+
+ for team_id, multiplier in skepticism_factors.items():
+ # Get player IDs for the specified team
+ players_on_team = df[df["team"] == team_id].index
+ # Apply the multiplier to the base_pts
+ df.loc[players_on_team, "base_pts"] *= multiplier
+
+ return df
+
+
+# --- Main Calculation Logic (encapsulated) ---
+def get_df_hash(df):
+ """Create a stable hash for a DataFrame"""
+ return hashlib.md5(pd.util.hash_pandas_object(df, index=True).values).hexdigest()
+
+
+def calculate_single_match_points(
+ player,
+ match_row,
+ xMins_in_match,
+ points_config,
+ is_gk=False,
+ is_def=False,
+ is_mid=False,
+ is_fwd=False,
+):
+ """
+ Calculates points for a single match given the xMins and match projections.
+ Includes full logic for CBIT, CBITR, Penalty Saves, and dynamic BPS.
+ """
+ if xMins_in_match <= 0:
+ return 0.0
+
+ scaling_factor = xMins_in_match / 90.0
+ player_team_num = player["team"]
+ player_pos = player["element_type"]
+
+ # 1. Identify Home/Away and get Opponent Stats
+ if player_team_num == match_row["home_team_num"]:
+ team_proj_goals = match_row["mc_home_goals_mean"]
+ team_conc_goals = match_row["mc_away_goals_mean"]
+ team_proj_assists = match_row["mc_home_assists_xa_mean"]
+ team_proj_cbit = match_row["mc_home_CBIT_mean"]
+ team_proj_cbitr = match_row["mc_home_CBITR_mean"]
+ team_proj_saves = match_row["mc_home_keeper_saves_mean"]
+ team_proj_yc = match_row["mc_home_yc_mean"]
+ team_proj_rc = match_row["mc_home_rc_mean"]
+ cs_odds = match_row["home_clean_sheet_odds"]
+ else:
+ team_proj_goals = match_row["mc_away_goals_mean"]
+ team_conc_goals = match_row["mc_home_goals_mean"]
+ team_proj_assists = match_row["mc_away_assists_xa_mean"]
+ team_proj_cbit = match_row["mc_away_CBIT_mean"]
+ team_proj_cbitr = match_row["mc_away_CBITR_mean"]
+ team_proj_saves = match_row["mc_away_keeper_saves_mean"]
+ team_proj_yc = match_row["mc_away_yc_mean"]
+ team_proj_rc = match_row["mc_away_rc_mean"]
+ cs_odds = match_row["away_clean_sheet_odds"]
+
+ # 2. Player Share Calculations
+ proj_goals = player["xG_share"] * team_proj_goals
+ proj_assists = player["xA_share"] * team_proj_assists
+
+ # CBIT / CBITR Projections
+ proj_cbit = player["xCBIT_share"] * team_proj_cbit
+ proj_cbitr = player["xCBITR_share"] * team_proj_cbitr
+
+ # GK Specific Projections
+ proj_saves = 0
+ proj_pen_saves = 0
+ if is_gk:
+ proj_saves = (player["baseline_xSaves_p90"] + team_proj_saves) / 2
+ proj_pen_saves = player["baseline_pksave_p90"]
+
+ # --- GOALS (Poisson) ---
+ pts_goals = 0.0
+ for k in range(9): # Check 0 to 8 goals
+ prob = poisson_pmf(k, proj_goals)
+ pts_per_goal = points_config["goal"][player_pos]
+ pts_goals += prob * k * pts_per_goal
+ pts_goals *= scaling_factor
+
+ # --- ASSISTS (Poisson) ---
+ pts_assists = 0.0
+ for k in range(9):
+ prob = poisson_pmf(k, proj_assists)
+ pts_assists += prob * k * points_config["assist"]
+ pts_assists *= scaling_factor
+
+ # --- CLEAN SHEET ---
+ pts_cs = 0.0
+ if xMins_in_match >= 60:
+ pts_cs = cs_odds * points_config["clean_sheet"][player_pos]
+ else:
+ pts_cs = (cs_odds * points_config["clean_sheet"][player_pos]) * scaling_factor
+
+ # --- CONCEDED ---
+ pts_conc = 0.0
+ if (is_gk or is_def) and team_conc_goals is not None:
+ # Expected points usually negative, calculated via Poisson
+ raw_conc = calculate_expected_conceded_points(team_conc_goals)
+ pts_conc = raw_conc * scaling_factor
+
+ # --- CARDS ---
+ pts_yc = (player["YC_share"] * team_proj_yc * -1) * scaling_factor
+ pts_rc = (player["RC_share"] * team_proj_rc * -3) * scaling_factor
+
+ # --- SAVES (GK) ---
+ pts_saves = 0.0
+ if is_gk:
+ # Saves points (1 pt per 3 saves)
+ # Using Negative Binomial approximation for saves distribution
+ expected_saves_pts_unscaled = 0.0
+ for k_saves in range(21):
+ prob_k = neg_binom_probability_of_value(proj_saves, k_saves, dispersion=1.5)
+ pts_k = (k_saves // 3) * points_config["saves_per_3"]
+ expected_saves_pts_unscaled += prob_k * pts_k
+ pts_saves = expected_saves_pts_unscaled * scaling_factor
+
+ # --- PENALTY SAVES (GK) ---
+ pts_pen_save = 0.0
+ if is_gk:
+ expected_pen_saved_pts_unscaled = 0.0
+ for k_pen in range(3):
+ prob_k = poisson_pmf(k_pen, proj_pen_saves)
+ pts_k = k_pen * 5
+ expected_pen_saved_pts_unscaled += prob_k * pts_k
+ pts_pen_save = expected_pen_saved_pts_unscaled * scaling_factor
+
+ # --- CBIT (Defenders) ---
+ pts_cbit = 0.0
+ if is_def:
+ cbit_threshold = 10
+ prob_hit = neg_binom_probability_at_least(
+ proj_cbit, cbit_threshold, dispersion=3.2
+ )
+ pts_cbit = prob_hit * 2 * scaling_factor
+
+ # --- CBITR (Mids/Fwds) ---
+ pts_cbitr = 0.0
+ if is_mid:
+ cbitr_threshold = 12
+ prob_hit = neg_binom_probability_at_least(
+ proj_cbitr, cbitr_threshold, dispersion=2.8
+ )
+ pts_cbitr = prob_hit * 2 * scaling_factor
+ elif is_fwd:
+ cbitr_threshold = 12
+ prob_hit = neg_binom_probability_at_least(
+ proj_cbitr, cbitr_threshold, dispersion=1.7
+ )
+ pts_cbitr = prob_hit * 2 * scaling_factor
+
+ # --- PENALTY POINTS (Taker) ---
+ pts_penalty = 0.0
+ # Assuming standard calculation or passed via config.
+ # If you have specific penalty taker logic, ensure 'penalty_pts_raw' is passed or calculated here.
+ # For now, using the basic structure from your loop:
+ if player["id"] in st.session_state.player_penalty_shares:
+ pen_share = st.session_state.player_penalty_shares[player["id"]]
+ base_pen_pts = points_config["penalty_points_per_position"].get(player_pos, 0)
+ pts_penalty = (base_pen_pts * pen_share) * scaling_factor
+
+ # --- APPEARANCE (Per Match) ---
+ pts_app = 0.0
+ if xMins_in_match > 60:
+ pts_app = 2
+ elif xMins_in_match > 0:
+ pts_app = 1
+
+ # --- BONUS POINTS (Detailed Reconstruction) ---
+
+ # 1. Floor
+ bps_floor = player["baseline_bps_floor_p90"] * scaling_factor
+
+ # 2. Mins Played BPS
+ bps_mins = 6 if xMins_in_match >= 60 else (3 if xMins_in_match > 0 else 0)
+
+ # 3. Events BPS (Approximation using Expected Counts)
+ # Using the RAW projected counts (scaled) to estimate BPS
+ scaled_goals = proj_goals * scaling_factor
+ scaled_assists = proj_assists * scaling_factor
+ scaled_saves = proj_saves * scaling_factor if is_gk else 0
+ scaled_pen_saves = proj_pen_saves * scaling_factor if is_gk else 0
+ scaled_yc = player["YC_share"] * team_proj_yc * scaling_factor
+ scaled_rc = player["RC_share"] * team_proj_rc * scaling_factor
+
+ bps_goals = 0
+ if is_fwd:
+ bps_goals = scaled_goals * 24
+ elif is_mid:
+ bps_goals = scaled_goals * 18
+ else:
+ bps_goals = scaled_goals * 12 # Def/GK
+
+ bps_assists = scaled_assists * 9
+
+ bps_cs = 0
+ if (is_gk or is_def) and xMins_in_match >= 60:
+ # Expected CS BPS = Probability of CS * 12 BPS
+ bps_cs = cs_odds * 12
+
+ bps_saves = scaled_saves * 2
+ bps_pen_saves = scaled_pen_saves * 15
+ bps_cards = (scaled_yc * -3) + (scaled_rc * -9)
+
+ total_projected_bps = (
+ bps_floor
+ + bps_mins
+ + bps_goals
+ + bps_assists
+ + bps_cs
+ + bps_saves
+ + bps_pen_saves
+ + bps_cards
+ )
+
+ pts_bonus = 0.0
+ if not is_gk:
+ # Your specific formula: Total BPS / 29.4
+ pts_bonus = total_projected_bps / 29.4
+
+ # --- FINAL SUM ---
+ total_match_pts = (
+ pts_goals
+ + pts_assists
+ + pts_cs
+ + pts_conc
+ + pts_yc
+ + pts_rc
+ + pts_saves
+ + pts_pen_save
+ + pts_cbit
+ + pts_cbitr
+ + pts_penalty
+ + pts_app
+ + pts_bonus
+ )
+
+ return total_match_pts
+
+
+# @st.cache_data(show_spinner=False, hash_funcs={pd.DataFrame: get_df_hash})
+def calculate_all_points(
+ player_df_base,
+ match_df,
+ player_penalty_shares,
+ MINS_SCALING_BONUS,
+ pos_map,
+ teams_dict_1,
+ teams_dict,
+ points_config,
+ effective_xmins_overrides, # Renamed parameter to reflect it's the merged overrides
+ MINS_THRESHOLD, # Pass decay related parameters
+ RAMP_UP_PERIOD, # Pass decay related parameters
+ decay_rates, # Pass decay related parameters
+ ramp_up_rates, # Pass decay related parameters
+ user_player_status_overrides, # Pass player status overrides
+ team_skepticism,
+ effective_availability_multipliers,
+):
+ RAMP_UP_PERIOD = 3
+ player_df = player_df_base.copy() # Work on a copy of the base player_df
+
+ # Initialize the output DataFrame structure
+ final_df_output = pd.DataFrame(
+ {
+ "Pos": player_df["element_type"].map(pos_map),
+ "ID": player_df["id"],
+ "Name": player_df["web_name"],
+ "BV": player_df["now_cost"],
+ "SV": player_df["now_cost"],
+ "Team": player_df["Team"],
+ }
+ )
+
+ # Persistence Setup
+ continuous_xMins_progression = player_df["baseline_xMins"].copy()
+ has_baseline_xmins_override = getattr(player_df, "attrs", {}).get(
+ "has_baseline_xmins_override", False
+ )
+ all_baseline_overrides = getattr(player_df, "attrs", {}).get(
+ "all_baseline_overrides", {}
+ )
+ unique_gws = sorted(match_df["GW"].unique())
+
+ st.session_state.group_cache = {}
+
+ # --- MAIN GAMEWEEK LOOP ---
+ for gw_idx, gw in enumerate(unique_gws):
+ # 1. Apply Baseline Overrides (First GW only)
+ if has_baseline_xmins_override and gw == 1:
+ for index, player in player_df.iterrows():
+ player_id = player["id"]
+ if (
+ player_id in all_baseline_overrides
+ and "baseline_xMins" in all_baseline_overrides[player_id]
+ ):
+ continuous_xMins_progression.loc[index] = all_baseline_overrides[
+ player_id
+ ]["baseline_xMins"]
+
+ # 2. Setup GW DataFrame
+ gw_calc_df = pd.DataFrame(index=player_df.index)
+ gw_calc_df["team"] = player_df["team"]
+ gw_calc_df["id"] = player_df["id"]
+ gw_calc_df["web_name"] = player_df["web_name"]
+ gw_calc_df["player_name"] = player_df["name"]
+ gw_calc_df["xG_share"] = player_df["xG_share"]
+ gw_calc_df["xA_share"] = player_df["xA_share"]
+ gw_calc_df["baseline_xMins"] = player_df["baseline_xMins"]
+ gw_calc_df["baseline_bps_floor_p90"] = player_df["baseline_bps_floor_p90"]
+ gw_calc_df["base_pts"] = 0.0
+
+ xMins_for_current_gw_display = pd.Series(index=player_df.index, dtype=float)
+ next_gw_continuous_xMins = pd.Series(index=player_df.index, dtype=float)
+
+ # ============================================================
+ # VECTORIZED XMINS CALCULATION (UNCHANGED)
+ # ============================================================
+ player_ids_array = player_df["id"].values
+ n_players = len(player_ids_array)
+
+ status_list = [
+ user_player_status_overrides.get(pid, {"status": "default"})["status"]
+ for pid in player_ids_array
+ ]
+ weeks_out_list = [
+ user_player_status_overrides.get(pid, {}).get("weeks_out", 0)
+ for pid in player_ids_array
+ ]
+
+ status_array = np.array(status_list, dtype=object)
+ weeks_out_array = np.array(weeks_out_list)
+
+ is_not_starter = status_array == "not_a_starter"
+ is_suspended = status_array == "suspended"
+ is_injured = status_array == "injured"
+ is_default = ~(is_not_starter | is_suspended | is_injured)
+
+ baseline_mins_array = player_df["baseline_xMins"].values
+ prev_continuous_xmins_array = continuous_xMins_progression.values
+
+ calculated_xmins_array = np.zeros(n_players, dtype=float)
+ next_continuous_xmins_array = np.zeros(n_players, dtype=float)
+
+ first_gw = min(unique_gws)
+ is_first_gw = gw == first_gw
+ is_available_first_gw = ~(is_not_starter | is_suspended | is_injured)
+
+ # CASE 1: First GW + Available
+ if is_first_gw:
+ mask_first_available = is_available_first_gw
+ calculated_xmins_array[mask_first_available] = baseline_mins_array[
+ mask_first_available
+ ]
+
+ # CASE 2: Not a starter
+ calculated_xmins_array[is_not_starter] = 0
+
+ # CASE 3: Suspended
+ mask_suspended_during = is_suspended & (gw <= weeks_out_array)
+ mask_suspended_return = is_suspended & (gw == weeks_out_array + 1)
+ mask_suspended_after = is_suspended & (gw > weeks_out_array + 1)
+
+ calculated_xmins_array[mask_suspended_during] = 0
+ calculated_xmins_array[mask_suspended_return] = baseline_mins_array[
+ mask_suspended_return
+ ]
+
+ decay_rate_susp = decay_rates.get("suspended", decay_rates.get("default", 0.99))
+ ramp_rate_susp = ramp_up_rates.get("suspended", ramp_up_rates.get("default", 0))
+
+ mask_susp_decay = mask_suspended_after & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ mask_susp_ramp = mask_suspended_after & (
+ prev_continuous_xmins_array < MINS_THRESHOLD
+ )
+
+ calculated_xmins_array[mask_susp_decay] = (
+ prev_continuous_xmins_array[mask_susp_decay] * decay_rate_susp
+ )
+ calculated_xmins_array[mask_susp_ramp] = np.minimum(
+ prev_continuous_xmins_array[mask_susp_ramp] + ramp_rate_susp, 90
+ )
+
+ # CASE 4: Injured
+ mask_injured_out = is_injured & (gw <= weeks_out_array)
+ calculated_xmins_array[mask_injured_out] = 0
+
+ mask_injured_recovering = is_injured & (gw > weeks_out_array)
+ weeks_since_injury_array = np.maximum(0, gw - weeks_out_array)
+
+ mask_ramp_phase = mask_injured_recovering & (
+ weeks_since_injury_array <= RAMP_UP_PERIOD
+ )
+ calculated_xmins_array[mask_ramp_phase] = (
+ baseline_mins_array[mask_ramp_phase] / RAMP_UP_PERIOD
+ ) * weeks_since_injury_array[mask_ramp_phase]
+
+ mask_post_ramp = mask_injured_recovering & (
+ weeks_since_injury_array > RAMP_UP_PERIOD
+ )
+
+ decay_rate_default = decay_rates.get("default", 0.99)
+ ramp_rate_default = ramp_up_rates.get(
+ "default", ramp_up_rates.get("injured", 0)
+ )
+
+ mask_post_decay = mask_post_ramp & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ mask_post_ramp_up = mask_post_ramp & (
+ prev_continuous_xmins_array < MINS_THRESHOLD
+ )
+
+ calculated_xmins_array[mask_post_decay] = (
+ prev_continuous_xmins_array[mask_post_decay] * decay_rate_default
+ )
+ calculated_xmins_array[mask_post_ramp_up] = np.minimum(
+ prev_continuous_xmins_array[mask_post_ramp_up] + ramp_rate_default, 90
+ )
+
+ # CASE 5: Default/healthy
+ mask_default_calc = is_default & ~(is_first_gw & is_available_first_gw)
+
+ element_type_array = player_df["element_type"].values
+ is_gk = element_type_array == 1
+
+ mask_gk_default = mask_default_calc & is_gk
+ calculated_xmins_array[mask_gk_default] = prev_continuous_xmins_array[
+ mask_gk_default
+ ]
+
+ mask_outfield_default = mask_default_calc & (~is_gk)
+
+ mask_outf_decay = mask_outfield_default & (
+ prev_continuous_xmins_array >= MINS_THRESHOLD
+ )
+ calculated_xmins_array[mask_outf_decay] = (
+ prev_continuous_xmins_array[mask_outf_decay] * decay_rate_default
+ )
+
+ mask_outf_ramp = (
+ mask_outfield_default
+ & (prev_continuous_xmins_array < MINS_THRESHOLD)
+ & (baseline_mins_array > 0)
+ )
+
+ calculated_xmins_array[mask_outf_ramp] = np.minimum(
+ prev_continuous_xmins_array[mask_outf_ramp] + ramp_rate_default, 90
+ )
+
+ calculated_xmins_array = np.clip(calculated_xmins_array, 0, 90)
+ next_continuous_xmins_array = calculated_xmins_array.copy()
+
+ # ==============================================
+ # APPLY OVERRIDES AND AVAILABILITY
+ # ==============================================
+ xMins_for_current_gw_display = calculated_xmins_array.copy()
+
+ for idx in range(n_players):
+ player_id = player_ids_array[idx]
+
+ # Apply Availability
+ availability_mult = 1.0
+ if player_id in effective_availability_multipliers:
+ if gw in effective_availability_multipliers[player_id]:
+ availability_mult = effective_availability_multipliers[player_id][
+ gw
+ ]
+ xMins_for_current_gw_display[idx] *= availability_mult
+
+ # Apply Manual Overrides
+ if player_id in effective_xmins_overrides:
+ if gw in effective_xmins_overrides[player_id]:
+ xMins_for_current_gw_display[idx] = effective_xmins_overrides[
+ player_id
+ ][gw]
+
+ xMins_for_current_gw_display = pd.Series(
+ xMins_for_current_gw_display, index=player_df.index
+ )
+ next_gw_continuous_xMins = pd.Series(
+ next_continuous_xmins_array, index=player_df.index
+ )
+
+ # Assign calculated xMins to DataFrame for the current GW
+ gw_calc_df[f"{gw}_xMins"] = xMins_for_current_gw_display
+
+ # ============================================================
+ # STREAMLINED MATCH SCORING LOOP (NEW FEATURE)
+ # ============================================================
+
+ gw_matches = match_df[match_df["GW"] == gw]
+
+ for index, player in player_df.iterrows():
+ player_team_num = player["team"]
+
+ # Find matches for this player in this GW
+ my_matches = gw_matches[
+ (gw_matches["home_team_num"] == player_team_num)
+ | (gw_matches["away_team_num"] == player_team_num)
+ ]
+
+ # If Blank GW, score is 0
+ if my_matches.empty:
+ gw_calc_df.loc[index, "base_pts"] = 0
+ gw_calc_df.loc[index, f"{gw}_xMins"] = 0
+ continue
+
+ # --- DGW FATIGUE LOGIC ---
+ # Get the base xMins calculated for this week (e.g. 90)
+ base_gw_mins = gw_calc_df.loc[index, f"{gw}_xMins"]
+ mins_per_match = base_gw_mins # Default for Single GW
+
+ # If DGW (match count > 1) AND player is expected to play significant mins:
+ # We apply a 0.95 factor per match to simulate rotation/fatigue.
+ # E.g. 90 mins becomes 85.5 mins PER MATCH (171 total), effectively capping 180.
+ if len(my_matches) > 1 and base_gw_mins > 35:
+ mins_per_match = base_gw_mins * 0.97
+
+ # Sum points for all matches in the gameweek
+ total_gw_pts = 0.0
+
+ for _, match_row in my_matches.iterrows():
+ # Call the Helper Function
+ pts = calculate_single_match_points(
+ player=player,
+ match_row=match_row,
+ xMins_in_match=mins_per_match,
+ points_config=points_config,
+ is_gk=(player["element_type"] == 1),
+ is_def=(player["element_type"] == 2),
+ is_mid=(player["element_type"] == 3),
+ is_fwd=(player["element_type"] == 4),
+ )
+ total_gw_pts += pts
+
+ gw_calc_df.loc[index, "base_pts"] = total_gw_pts
+
+ # Apply Team Skepticism
+ gw_calc_df = apply_team_skepticism(gw_calc_df, team_skepticism)
+
+ # Final Total Points for this GW
+ gw_calc_df["total_pts"] = gw_calc_df["base_pts"]
+
+ # Store in Final Output
+ final_df_output[f"{gw}_xMins"] = round(gw_calc_df[f"{gw}_xMins"], 0)
+ final_df_output[f"{gw}_Pts"] = round(gw_calc_df["total_pts"], 2)
+
+ # Update progression for next loop
+ continuous_xMins_progression = next_gw_continuous_xMins.copy()
+
+ # Calculate Totals and Averages
+ final_df_output["Total Points"] = final_df_output.filter(like="_Pts").sum(axis=1)
+ final_df_output["Average Points"] = round(
+ (final_df_output.filter(like="_Pts").sum(axis=1)) / len(unique_gws), 2
+ )
+
+ return final_df_output
+
+
+def get_modified_finalized_df():
+ """Function to apply baseline overrides to the finalized_df"""
+ if st.session_state.finalized_df is None:
+ return None
+ modified_df = st.session_state.finalized_df.copy()
+
+ direct_override_stats = [
+ "baseline_xMins",
+ "baseline_pksave_p90",
+ "Avg_BPS",
+ ] # Avg_BPS is also a direct override
+
+ all_overrides = st.session_state.user_baseline_overrides.copy()
+ has_baseline_xmins_override = any(
+ "baseline_xMins" in overrides for overrides in all_overrides.values()
+ )
+
+ for player_id, overrides in st.session_state.user_baseline_overrides.items():
+ mask = modified_df["id"] == player_id
+ if mask.any():
+ for stat_name, override_value in overrides.items():
+ if stat_name in modified_df.columns:
+ if stat_name in direct_override_stats:
+ # Apply direct value override
+ modified_df.loc[mask, stat_name] = override_value
+ else:
+ # Apply multiplier override
+ original_value = st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == player_id
+ ].iloc[0][stat_name]
+ modified_df.loc[mask, stat_name] = (
+ original_value * override_value
+ )
+
+ # After applying direct and multiplier overrides for *all* baseline stats,
+ # now recalculate Avg_BPS for players where it hasn't been directly overridden.
+ # The 'modified_df' now contains the adjusted baseline stats for other metrics
+ # which will feed into this Avg_BPS calculation.
+ for index, row in modified_df.iterrows():
+ player_id = row["id"]
+ # Check if Avg_BPS was directly overridden by the admin.
+ # If it was, we DO NOT recalculate it; we use the overridden value already applied.
+ if (
+ player_id in st.session_state.user_baseline_overrides
+ and "Avg_BPS" in st.session_state.user_baseline_overrides[player_id]
+ ):
+ # The direct override for Avg_BPS would have already been applied earlier in this function.
+ # So, we simply skip recalculation for this player.
+ continue
+
+ # If Avg_BPS was NOT directly overridden, then calculate it based on
+ # the (potentially adjusted by multipliers) other baseline stats.
+ if row["element_type"] == 1:
+ modified_df.loc[index, "Avg_BPS"] = row["baseline_gk_bps_p90"]
+ elif row["element_type"] == 2:
+ modified_df.loc[index, "Avg_BPS"] = (
+ row["baseline_Neutral_BPS_p90"] + row["baseline_Def_BPS_p90"]
+ )
+ elif row["element_type"] == 3:
+ modified_df.loc[index, "Avg_BPS"] = (
+ row["baseline_Neutral_BPS_p90"] + row["baseline_Mid_BPS_p90"]
+ )
+ elif row["element_type"] == 4:
+ modified_df.loc[index, "Avg_BPS"] = (
+ row["baseline_Neutral_BPS_p90"] + row["baseline_Fwd_BPS_p90"]
+ )
+
+ modified_df.attrs["has_baseline_xmins_override"] = has_baseline_xmins_override
+ modified_df.attrs["all_baseline_overrides"] = all_overrides
+
+ return modified_df
+
+
+# Function to recalculate with current overrides
+def recalculate_projections():
+ """Recalculate projections with current overrides"""
+ # Get the dataframe with the applied baseline overrides
+ if st.session_state.finalized_df is None:
+ return
+ modified_finalized_df = get_modified_finalized_df()
+
+ # Recalculate player shares using the modified_finalized_df
+ # This is crucial for non-xMins baseline updates to take effect
+ modified_finalized_df = recalculate_player_shares(
+ modified_finalized_df, st.session_state.team_baselines
+ )
+
+ # Determine the effective xMins overrides to pass to calculate_all_points
+ # Admin-persistent overrides are the base, and session-only overrides are layered on top.
+ effective_xmins_overrides = {
+ pid: gw_data.copy()
+ for pid, gw_data in st.session_state.admin_persistent_xmins_overrides.items()
+ }
+
+ # Now, overlay user's session-only overrides (these are temporary and override if present)
+ for player_id, gw_data in st.session_state.user_xmins_overrides.items():
+ if player_id not in effective_xmins_overrides:
+ effective_xmins_overrides[player_id] = {}
+ effective_xmins_overrides[player_id].update(gw_data)
+
+ effective_availability_multipliers = {
+ pid: gw_data.copy()
+ for pid, gw_data in st.session_state.admin_persistent_availability_multipliers.items()
+ }
+
+ # Now, overlay user's session-only availability multipliers
+ for player_id, gw_data in st.session_state.user_availability_multipliers.items():
+ if player_id not in effective_availability_multipliers:
+ effective_availability_multipliers[player_id] = {}
+ effective_availability_multipliers[player_id].update(gw_data)
+
+ st.session_state.output_df = calculate_all_points(
+ modified_finalized_df, # Pass the *fully modified* DataFrame
+ st.session_state.match_df,
+ st.session_state.player_penalty_shares,
+ st.session_state.MINS_SCALING_BONUS,
+ st.session_state.pos_map,
+ st.session_state.teams_dict_1,
+ st.session_state.teams_dict,
+ st.session_state.points_config,
+ effective_xmins_overrides, # Pass the effective dictionary
+ st.session_state.MINS_THRESHOLD,
+ st.session_state.RAMP_UP_PERIOD,
+ st.session_state.decay_rates,
+ st.session_state.ramp_up_rates,
+ st.session_state.user_player_status_overrides,
+ st.session_state.team_skepticism,
+ effective_availability_multipliers,
+ )
+
+
+def apply_loaded_baseline_overrides_on_startup():
+ """
+ Initializes bps_std_devs from the base finalized_df.
+ It does NOT apply baseline overrides to finalized_df directly here;
+ that is handled by get_modified_finalized_df().
+ """
+ if st.session_state.finalized_df is not None:
+ # Recalculate bps_std_devs based on the *pristine* finalized_df
+ # This is important for initial display before any user overrides are applied.
+ st.session_state.bps_std_devs = (
+ st.session_state.finalized_df.groupby("element_type")["Avg_BPS"]
+ .std()
+ .to_dict()
+ )
+
+
+def update_sync_value_from_slider(sync_key, slider_key):
+ """Updates a session state variable with the value from a slider."""
+ st.session_state[sync_key] = st.session_state[slider_key]
+
+
+def update_sync_value_from_number_input(sync_key, num_input_key):
+ """Updates a session state variable with the value from a number input."""
+ st.session_state[sync_key] = st.session_state[num_input_key]
+
+
+# --- Streamlit Application Layout and Logic ---
+
+im = Image.open("image.png")
+st.set_page_config(layout="wide", page_title="Luigi's Mansion", page_icon=im)
+
+st.title("Luigi's Mansion")
+st.markdown(
+ "Yes you can play around with xMins here (I made it more for my convenience rather than yours but nonetheless, enjoy!)"
+)
+st.markdown("---")
+
+# Load any existing admin overrides (baselines, status, penalties, global rates)
+# user_xmins_overrides is NOT loaded here; it is session-only.
+load_admin_overrides()
+
+# Initial call to setup and calculate data (only runs once or on explicit rerun)
+if not st.session_state.initialized:
+ # Use a spinner to indicate loading
+ with st.spinner(
+ "Preparing data and running initial projections... This may take a while..."
+ ):
+ initial_finalized_df = load_data_and_setup_initial_df()
+ if initial_finalized_df is None: # Handle case where initial data load failed
+ st.stop() # Stop execution if data isn't loaded correctly
+
+ # Load team_baselines here once
+ teams_dict = TEAMS_DICT
+ # Correctly load team_baselines from file
+ try:
+ st.session_state.team_baselines = pd.read_excel(
+ "team_totals.xlsx", sheet_name="Sheet2"
+ )
+ st.session_state.team_baselines["Teams"] = st.session_state.team_baselines[
+ "Teams"
+ ].replace(teams_dict)
+ except FileNotFoundError:
+ st.error(
+ "Error: team_totals.xlsx not found. Please ensure it's in the same directory as the app."
+ )
+ st.stop()
+ except Exception as e:
+ st.error(f"Error loading team_totals.xlsx: {e}")
+ st.stop()
+
+ finalized_df, match_df, teams_dict_updated, teams_dict_1_updated = (
+ load_data_and_setup_initial_df_2(initial_finalized_df)
+ )
+
+ if (
+ finalized_df is None
+ ): # match_df, teams_dict, teams_dict_1 would also be None if load_data_and_setup_initial_df_2 failed
+ st.stop() # Stop execution if data isn't loaded correctly
+
+ # MINS_SCALING_BONUS is explicitly 0.0 at initialization.
+ st.session_state.MINS_SCALING_BONUS = 0.0
+ # Ensure bps_std_devs is calculated based on the loaded data
+ st.session_state.pos_map = {1: "G", 2: "D", 3: "M", 4: "F"}
+ st.session_state.teams_dict_1 = teams_dict_1_updated
+ st.session_state.teams_dict = teams_dict_updated
+
+ # FIX: Adjusted points_config to remove CBIT/CBITR related multipliers, as they are now tiered.
+ st.session_state.points_config = {
+ "goal": {1: 10, 2: 6, 3: 5, 4: 4},
+ "assist": 3,
+ "clean_sheet": {1: 4, 2: 4, 3: 1, 4: 0},
+ "saves_per_3": 1,
+ "penalty_points_per_position": {2: 0.9, 3: 0.7, 4: 0.5},
+ }
+
+ # Store the base dataframes
+ st.session_state.match_df = match_df
+ st.session_state.finalized_df = finalized_df
+
+ apply_loaded_baseline_overrides_on_startup() # Applies baseline overrides to st.session_state.finalized_df
+
+ # Perform the initial full calculation by calling the recalculation function
+ # This ensures all loaded overrides (baselines, status, penalty shares, rates,
+ # and admin_persistent_xmins_overrides) are properly incorporated into the output_df.
+ recalculate_projections()
+ st.session_state.initialized = True
+
+ # Initialize selected player state variables after data is loaded
+ # We need to map the ID to the unique display name for initial selection
+ if (
+ st.session_state.output_df is not None
+ and not st.session_state.output_df.empty
+ ):
+ # Create a dictionary to map player ID to its unique display string
+ player_id_to_display_name_map = {
+ row["id"]: f"{row['web_name']} (ID: {row['id']})"
+ for _, row in st.session_state.finalized_df[
+ ["id", "web_name"]
+ ].iterrows()
+ }
+ player_display_names = list(
+ player_id_to_display_name_map.values()
+ ) # Moved here for consistency
+
+ # Set initial selected players using their unique display names
+ if st.session_state.selected_xm_player:
+ # Find player ID by name in output_df (assuming Name column corresponds to web_name)
+ # and then get the display name from the map to ensure consistency.
+ player_id_from_name = st.session_state.finalized_df[
+ st.session_state.finalized_df["web_name"]
+ == st.session_state.selected_xm_player.split(" (ID:")[
+ 0
+ ] # Extract web_name
+ ]["id"].iloc[0]
+ st.session_state.selected_xm_player = player_id_to_display_name_map.get(
+ player_id_from_name
+ )
+ elif player_display_names: # Check if list is not empty before accessing
+ st.session_state.selected_xm_player = player_display_names[0]
+
+ if st.session_state.selected_status_player:
+ player_id_from_name = st.session_state.finalized_df[
+ st.session_state.finalized_df["web_name"]
+ == st.session_state.selected_status_player.split(" (ID:")[0]
+ ]["id"].iloc[0]
+ st.session_state.selected_status_player = (
+ player_id_to_display_name_map.get(player_id_from_name)
+ )
+ elif player_display_names: # Check if list is not empty before accessing
+ st.session_state.selected_status_player = player_display_names[0]
+
+ if st.session_state.selected_baseline_player:
+ player_id_from_name = st.session_state.finalized_df[
+ st.session_state.finalized_df["web_name"]
+ == st.session_state.selected_baseline_player.split(" (ID:")[0]
+ ]["id"].iloc[0]
+ st.session_state.selected_baseline_player = (
+ player_id_to_display_name_map.get(player_id_from_name)
+ )
+ elif player_display_names: # Check if list is not empty before accessing
+ st.session_state.selected_baseline_player = player_display_names[0]
+
+ if st.session_state.selected_penalty_player:
+ player_id_from_name = st.session_state.finalized_df[
+ st.session_state.finalized_df["web_name"]
+ == st.session_state.selected_penalty_player.split(" (ID:")[0]
+ ]["id"].iloc[0]
+ st.session_state.selected_penalty_player = (
+ player_id_to_display_name_map.get(player_id_from_name)
+ )
+ elif player_display_names: # Check if list is not empty before accessing
+ st.session_state.selected_penalty_player = player_display_names[0]
+
+ st.success(
+ "Data loaded and initial calculations complete! Use the sidebar to make adjustments."
+ )
+
+
+# --- SIDEBAR EDITING FUNCTIONALITY ---
+with st.sidebar:
+ st.header("Adjust Player Stats")
+
+ # Admin Login Section
+ st.subheader("Admin Login")
+ admin_password_input = st.text_input(
+ "Enter Admin Password", type="password", key="admin_password_input"
+ )
+ if admin_password_input == ADMIN_PASSWORD:
+ st.session_state.is_admin_logged_in = True
+ st.success("Admin logged in!")
+ elif admin_password_input != "" and admin_password_input != ADMIN_PASSWORD:
+ st.session_state.is_admin_logged_in = False
+ st.error("Incorrect password.")
+
+ if st.session_state.is_admin_logged_in:
+ if st.button("Log Out Admin", key="admin_logout_button"):
+ st.session_state.is_admin_logged_in = False
+ st.rerun()
+
+ st.divider()
+
+ # Create a mapping for player names and IDs for display
+ # This ensures unique selection even for players with the same web_name
+ player_id_to_display_name_map = {}
+ player_display_name_to_id_map = {} # Initialize the reverse map
+ player_display_names = []
+
+ # Ensure finalized_df exists before attempting to create the map and list
+ if st.session_state.finalized_df is not None:
+ player_id_to_display_name_map = {
+ row["id"]: f"{row['web_name']} (ID: {row['id']})"
+ for _, row in st.session_state.finalized_df[["id", "web_name"]].iterrows()
+ }
+ player_display_names = list(player_id_to_display_name_map.values())
+ player_display_name_to_id_map = { # Populate the reverse map
+ v: k for k, v in player_id_to_display_name_map.items()
+ }
+
+ # Minutes Editor Section (Always visible for all users)
+ st.subheader("Adjust Expected Minutes (Weekly Override)")
+
+ # Ensure output_df is not None before trying to access its columns
+ gw_cols = []
+ if st.session_state.output_df is not None:
+ gw_cols = [
+ c for c in st.session_state.output_df.columns if c.endswith("_xMins")
+ ]
+ gw_choices = sorted(
+ [int(col.split("_")[0]) for col in gw_cols]
+ ) # Ensure sorted integers
+
+ # Determine initial index for selected_xm_player
+ initial_xm_index = 0
+ if (
+ st.session_state.selected_xm_player
+ and st.session_state.selected_xm_player in player_display_names
+ ):
+ initial_xm_index = player_display_names.index(
+ st.session_state.selected_xm_player
+ )
+ elif player_display_names: # Check if list is not empty before accessing
+ st.session_state.selected_xm_player = player_display_names[0]
+ initial_xm_index = 0
+
+ player_selected_display = st.selectbox(
+ "Select Player for xMins Override",
+ player_display_names,
+ key="xm_sidebar_player",
+ index=initial_xm_index,
+ on_change=lambda: setattr(
+ st.session_state, "selected_xm_player", st.session_state.xm_sidebar_player
+ ),
+ )
+
+ # Get the actual player ID from the selected display name
+ player_id = player_display_name_to_id_map.get(player_selected_display)
+
+ gw_selected = st.selectbox(
+ "Select Gameweek for xMins Override", gw_choices, key="xm_sidebar_gw"
+ )
+
+ if (
+ player_id is not None and gw_selected and st.session_state.output_df is not None
+ ): # Use player_id for lookup and check output_df
+ gw = int(gw_selected)
+
+ # Get the current projected xMins for this player and GW (which includes baseline and decay)
+ current_projected_xmins_col = f"{gw}_xMins"
+ # Use player_id for lookup in output_df
+ current_projected_xmins = st.session_state.output_df[
+ st.session_state.output_df["ID"] == player_id
+ ][current_projected_xmins_col].iloc[0]
+
+ if st.session_state.is_admin_logged_in:
+ st.info(
+ "Your changes here will be **saved** across sessions because you are logged in as admin."
+ )
+ # Use the admin persistent override if it exists, otherwise use the current projected value as default
+ default_val = st.session_state.admin_persistent_xmins_overrides.get(
+ player_id, {}
+ ).get(gw, current_projected_xmins)
+
+ new_xmins = st.number_input(
+ "Expected Minutes",
+ 0.0, # min_value
+ 90.0, # max_value
+ float(default_val), # value
+ step=1.0, # Changed step to 0.01
+ format="%.2f",
+ key=f"xmins_sidebar_slider_{player_id}_{gw}",
+ )
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button(
+ "Update xMins Override", key=f"update_xmins_sidebar_{player_id}_{gw}"
+ ):
+ st.session_state.admin_persistent_xmins_overrides.setdefault(
+ player_id, {}
+ )[gw] = float(new_xmins)
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Auto-save for admin
+ st.success("Minutes override updated and saved!")
+ st.info(
+ "Please wait 2-3 seconds for the data to fully update before making further changes."
+ )
+ st.rerun()
+
+ if col2.button(
+ "Reset xMins Override", key=f"reset_xmins_sidebar_{player_id}_{gw}"
+ ):
+ if player_id in st.session_state.admin_persistent_xmins_overrides:
+ st.session_state.admin_persistent_xmins_overrides[player_id].pop(
+ gw, None
+ )
+ if not st.session_state.admin_persistent_xmins_overrides[player_id]:
+ del st.session_state.admin_persistent_xmins_overrides[player_id]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Auto-save for admin
+ st.success("xMins override reset to default and saved!")
+ st.info(
+ "Please wait 2-3 seconds for the data to fully update before making further changes."
+ )
+ st.rerun()
+
+ st.markdown("---")
+ st.subheader("🔗 Connected Groups (Redistribution)")
+
+ if (
+ st.session_state.finalized_df is None
+ or st.session_state.finalized_df.empty
+ ):
+ st.warning(
+ "⚠️ Data not loaded. Please click 'Update Projections' first."
+ )
+ else:
+ df_source = st.session_state.finalized_df.copy() # Work on a copy
+
+ # 1. Find Name Column
+ name_col = None
+ for c in ["Name", "web_name", "player_name", "name", "Player"]:
+ if c in df_source.columns:
+ name_col = c
+ break
+
+ # 2. Find ID Column (Crucial for differentiation)
+ id_col = "id" if "id" in df_source.columns else None
+
+ if not name_col or not id_col:
+ st.error(
+ f"❌ Missing Name or ID column. Cols: {list(df_source.columns)}"
+ )
+ else:
+ # 3. Create Unique Display Names: "Name (ID)"
+ # This handles duplicate names like 'Wilson'
+ df_source["unique_display"] = (
+ df_source[name_col].astype(str)
+ + " ("
+ + df_source[id_col].astype(str)
+ + ")"
+ )
+ all_player_options = sorted(
+ df_source["unique_display"].unique().tolist()
+ )
+
+ # 4. Group Selector
+ existing_groups = list(st.session_state.player_groups.keys())
+ group_action = st.radio(
+ "Action",
+ ["Edit Existing", "Create New"],
+ horizontal=True,
+ key="grp_action_unique",
+ )
+
+ selected_group_name = None
+ if group_action == "Create New":
+ selected_group_name = st.text_input("Enter New Group Name")
+ elif existing_groups:
+ selected_group_name = st.selectbox(
+ "Select Group to Edit", existing_groups
+ )
+
+ # 5. Member Selector
+ if selected_group_name:
+ current_members = st.session_state.player_groups.get(
+ selected_group_name, []
+ )
+
+ updated_members = st.multiselect(
+ f"Members of '{selected_group_name}'",
+ options=all_player_options,
+ default=[
+ p for p in current_members if p in all_player_options
+ ],
+ )
+
+ c1, c2 = st.columns(2)
+ if c1.button("💾 Save Group"):
+ st.session_state.player_groups[selected_group_name] = (
+ updated_members
+ )
+ save_player_groups(st.session_state.player_groups)
+ st.success(f"Saved group '{selected_group_name}'")
+
+ if c2.button("❌ Delete Group"):
+ if selected_group_name in st.session_state.player_groups:
+ del st.session_state.player_groups[selected_group_name]
+ save_player_groups(st.session_state.player_groups)
+ st.rerun()
+
+ else: # Not admin logged in
+ st.info(
+ "Your changes here are only for your current session and will NOT be saved."
+ )
+ st.info(
+ "Remember to press the 'Update Button' once you have entered the desired value."
+ )
+ # Use the user session-only override if it exists, otherwise use the current projected value as default
+ default_val = st.session_state.user_xmins_overrides.get(player_id, {}).get(
+ gw, current_projected_xmins
+ )
+
+ new_xmins = st.number_input(
+ "Expected Minutes",
+ 0.0, # min_value
+ 90.0, # max_value
+ float(default_val), # value
+ step=1.0, # Changed step to 0.01
+ format="%.2f",
+ key=f"xmins_sidebar_slider_{player_id}_{gw}",
+ )
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button(
+ "Update xMins Override", key=f"update_xmins_sidebar_{player_id}_{gw}"
+ ):
+ st.session_state.user_xmins_overrides.setdefault(player_id, {})[gw] = (
+ float(new_xmins)
+ )
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success("Minutes override updated for this session!")
+ st.info(
+ "Please wait 2-3 seconds for the data to fully update before making further changes."
+ )
+ st.rerun()
+
+ if col2.button(
+ "Reset xMins Override", key=f"reset_xmins_sidebar_{player_id}_{gw}"
+ ):
+ if player_id in st.session_state.user_xmins_overrides:
+ st.session_state.user_xmins_overrides[player_id].pop(gw, None)
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success("xMins override reset to default for this session!")
+ st.info(
+ "Please wait 2-3 seconds for the data to fully update before making further changes."
+ )
+ st.rerun()
+
+ st.divider()
+
+ # --- Non-Admin Baseline xMins Adjustment ---
+ if not st.session_state.is_admin_logged_in:
+ st.subheader("Adjust Baseline Expected Minutes (Temporary)")
+ st.info(
+ "Your changes here are only for your current session and will NOT be saved."
+ )
+ st.info(
+ "Remember to press the 'Update Button' once you have entered the desired value."
+ )
+
+ # Determine initial index for selected_baseline_player
+ initial_baseline_index = 0
+ if (
+ st.session_state.selected_baseline_player
+ and st.session_state.selected_baseline_player in player_display_names
+ ):
+ initial_baseline_index = player_display_names.index(
+ st.session_state.selected_baseline_player
+ )
+ elif player_display_names:
+ st.session_state.selected_baseline_player = player_display_names[0]
+ initial_baseline_index = 0
+
+ baseline_player_selected_display_non_admin = st.selectbox(
+ "Select Player for Baseline xMins Editing",
+ options=player_display_names,
+ key="baseline_sidebar_player_non_admin",
+ index=initial_baseline_index,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_baseline_player",
+ st.session_state.baseline_sidebar_player_non_admin,
+ ),
+ )
+
+ if (
+ baseline_player_selected_display_non_admin
+ and st.session_state.finalized_df is not None
+ ):
+ player_id_non_admin = player_display_name_to_id_map.get(
+ baseline_player_selected_display_non_admin
+ )
+
+ # Only allow editing of baseline_xMins for non-admins
+ selected_stat_non_admin = "baseline_xMins"
+
+ # Get current value (either overridden or original)
+ if (
+ player_id_non_admin in st.session_state.user_baseline_overrides
+ and selected_stat_non_admin
+ in st.session_state.user_baseline_overrides[player_id_non_admin]
+ ):
+ current_val_non_admin = st.session_state.user_baseline_overrides[
+ player_id_non_admin
+ ][selected_stat_non_admin]
+ else:
+ player_row_non_admin = st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == player_id_non_admin
+ ].iloc[0]
+ current_val_non_admin = player_row_non_admin[selected_stat_non_admin]
+
+ new_stat_value_non_admin = st.number_input(
+ "Expected Baseline Minutes",
+ min_value=0.0, # min_value
+ max_value=90.0, # max_value
+ value=float(current_val_non_admin), # Ensure float
+ step=1.0, # Changed step to 0.01,
+ format="%.2f",
+ key=f"baseline_slider_non_admin_{player_id_non_admin}",
+ )
+
+ col1_non_admin, col2_non_admin = st.columns([1, 1])
+ if col1_non_admin.button(
+ "Update Baseline xMins",
+ key=f"update_baseline_non_admin_{player_id_non_admin}",
+ ):
+ if player_id_non_admin not in st.session_state.user_baseline_overrides:
+ st.session_state.user_baseline_overrides[player_id_non_admin] = {}
+ st.session_state.user_baseline_overrides[player_id_non_admin][
+ selected_stat_non_admin
+ ] = new_stat_value_non_admin
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success("Baseline xMins updated for this session!")
+ st.rerun()
+
+ if col2_non_admin.button(
+ "Reset Baseline xMins",
+ key=f"reset_baseline_non_admin_{player_id_non_admin}",
+ ):
+ if (
+ player_id_non_admin in st.session_state.user_baseline_overrides
+ and selected_stat_non_admin
+ in st.session_state.user_baseline_overrides[player_id_non_admin]
+ ):
+ del st.session_state.user_baseline_overrides[player_id_non_admin][
+ selected_stat_non_admin
+ ]
+ if not st.session_state.user_baseline_overrides[
+ player_id_non_admin
+ ]:
+ del st.session_state.user_baseline_overrides[
+ player_id_non_admin
+ ]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success("Baseline xMins reset to default for this session!")
+ st.rerun()
+ st.divider()
+
+ if not st.session_state.is_admin_logged_in:
+ st.subheader("Adjust Availability (Temporary)")
+ st.info(
+ "Your changes here are only for your current session and will NOT be saved."
+ )
+ st.info(
+ "Remember to press the 'Update Button' once you have entered the desired value."
+ )
+
+ # Determine initial index for selected_availability_player_non_admin
+ initial_availability_index_non_admin = 0
+ if (
+ st.session_state.selected_availability_player_non_admin
+ and st.session_state.selected_availability_player_non_admin
+ in player_display_names
+ ):
+ initial_availability_index_non_admin = player_display_names.index(
+ st.session_state.selected_availability_player_non_admin
+ )
+ elif player_display_names:
+ st.session_state.selected_availability_player_non_admin = (
+ player_display_names[0]
+ )
+ initial_availability_index_non_admin = 0
+
+ availability_player_selected_display_non_admin = st.selectbox(
+ "Select Player to edit Availability for",
+ options=player_display_names,
+ key="availability_sidebar_player_non_admin",
+ index=initial_availability_index_non_admin,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_availability_player_non_admin",
+ st.session_state.availability_sidebar_player_non_admin,
+ ),
+ )
+
+ if availability_player_selected_display_non_admin:
+ player_id = player_display_name_to_id_map.get(
+ availability_player_selected_display_non_admin
+ )
+
+ gw_selected = st.selectbox(
+ "Select the gameweek to edit Availability for",
+ gw_choices,
+ key="availability_sidebar_gw_non_admin",
+ )
+
+ if player_id is not None and gw_selected:
+ gw = int(gw_selected)
+
+ admin_multiplier = None
+ if (
+ player_id
+ in st.session_state.admin_persistent_availability_multipliers
+ and gw
+ in st.session_state.admin_persistent_availability_multipliers[
+ player_id
+ ]
+ ):
+ admin_multiplier = (
+ st.session_state.admin_persistent_availability_multipliers[
+ player_id
+ ][gw]
+ )
+ # Get current multiplier or default to 1.0
+ if admin_multiplier is not None:
+ current_multiplier = admin_multiplier
+ else:
+ current_multiplier = (
+ st.session_state.user_availability_multipliers.get(
+ player_id, {}
+ ).get(gw, 1.0)
+ )
+
+ st.info("Availability is set as a multiplier, ranging from 0-1")
+ new_multiplier = st.slider(
+ "Availability",
+ min_value=0.0,
+ max_value=1.0,
+ step=0.01,
+ value=float(current_multiplier),
+ key=f"availability_slider_non_admin_{player_id}_{gw}",
+ )
+ st.info(f"Availability for GW{gw} set at: {(current_multiplier):.2f}")
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button(
+ "Update Availability",
+ key=f"update_availability_non_admin_{player_id}_{gw}",
+ ):
+ st.session_state.user_availability_multipliers.setdefault(
+ player_id, {}
+ )[gw] = new_multiplier
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success("Availability multiplier updated for this session!")
+ st.rerun()
+
+ if col2.button(
+ "Reset Availability",
+ key=f"reset_availability_non_admin_{player_id}_{gw}",
+ ):
+ if player_id in st.session_state.user_availability_multipliers:
+ st.session_state.user_availability_multipliers[player_id].pop(
+ gw, None
+ )
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ st.success(
+ "Availability multiplier reset to default for this session!"
+ )
+ st.rerun()
+
+ st.divider()
+
+ # Admin-Only Sections (Conditional Rendering)
+ if st.session_state.is_admin_logged_in:
+ # Removed the top-level "Save Admin Changes" button.
+ # Saves will now be automatic with each update/reset.
+
+ # Adjust Player Status Section
+ st.subheader("Adjust Player Status (Admin)")
+
+ # Determine initial index for selected_status_player
+ initial_status_index = 0
+ if (
+ st.session_state.selected_status_player
+ and st.session_state.selected_status_player in player_display_names
+ ):
+ initial_status_index = player_display_names.index(
+ st.session_state.selected_status_player
+ )
+ elif (
+ player_display_names
+ ): # Added check to ensure player_display_names is not empty
+ st.session_state.selected_status_player = player_display_names[0]
+ initial_status_index = 0
+
+ status_player_selected_display = st.selectbox(
+ "Select Player to Adjust Status",
+ options=player_display_names,
+ key="status_sidebar_player",
+ index=initial_status_index,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_status_player",
+ st.session_state.status_sidebar_player,
+ ),
+ )
+
+ if status_player_selected_display: # Use player_id from map
+ player_id = player_display_name_to_id_map.get(
+ status_player_selected_display
+ )
+
+ # Get current status info for the player
+ current_status_info = st.session_state.user_player_status_overrides.get(
+ player_id, {"status": "default"}
+ )
+ current_status_type = current_status_info.get("status", "default")
+ current_weeks_out = current_status_info.get("weeks_out", 0)
+
+ status_options = [
+ "default",
+ "not_a_starter",
+ "suspended",
+ "injured",
+ "rotational_risk",
+ ]
+ new_status_type = st.selectbox(
+ "Select Status",
+ options=status_options,
+ index=status_options.index(current_status_type),
+ key=f"status_selector_{player_id}",
+ )
+
+ new_weeks_out = current_weeks_out
+ if new_status_type in ["suspended", "injured"]:
+ new_weeks_out = st.number_input(
+ "Weeks Out (Player returns after this GW)",
+ min_value=0,
+ value=int(current_weeks_out),
+ step=1,
+ key=f"weeks_out_input_{player_id}",
+ )
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button("Update Status", key=f"update_status_{player_id}"):
+ updated_status_info = {"status": new_status_type}
+ if new_status_type in ["suspended", "injured"]:
+ updated_status_info["weeks_out"] = int(new_weeks_out)
+
+ st.session_state.user_player_status_overrides[player_id] = (
+ updated_status_info
+ )
+
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Automatic save
+ st.success(
+ f"Status for {status_player_selected_display} updated and saved!"
+ )
+ st.rerun()
+
+ if col2.button("Reset Status", key=f"reset_status_{player_id}"):
+ if player_id in st.session_state.user_player_status_overrides:
+ del st.session_state.user_player_status_overrides[player_id]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Automatic save
+ st.success(
+ f"Status for {status_player_selected_display} reset to default and saved!"
+ )
+ st.rerun()
+
+ st.divider()
+
+ # Baseline Stats Editor Section (Existing)
+ st.subheader("Adjust Baseline Stats (Admin)")
+
+ initial_baseline_index = 0
+ if (
+ st.session_state.selected_baseline_player
+ and st.session_state.selected_baseline_player in player_display_names
+ ):
+ initial_baseline_index = player_display_names.index(
+ st.session_state.selected_baseline_player
+ )
+ elif (
+ player_display_names
+ ): # Added check to ensure player_display_names is not empty
+ st.session_state.selected_baseline_player = player_display_names[0]
+ initial_baseline_index = 0
+
+ baseline_player_selected_display = st.selectbox(
+ "Select Player for Baseline Editing",
+ options=player_display_names,
+ key="baseline_sidebar_player",
+ index=initial_baseline_index,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_baseline_player",
+ st.session_state.baseline_sidebar_player,
+ ),
+ )
+
+ if (
+ baseline_player_selected_display
+ and st.session_state.finalized_df is not None
+ ): # Use player_id from map and check finalized_df
+ player_id = player_display_name_to_id_map.get(
+ baseline_player_selected_display
+ )
+
+ # Define stats that are direct value overrides vs. multiplier overrides
+ direct_override_stats = ["baseline_xMins", "baseline_pksave_p90", "Avg_BPS"]
+ multiplier_override_stats = [
+ "baseline_xSaves_p90",
+ "baseline_xA_p90",
+ "baseline_yc_p90",
+ "baseline_rc_p90",
+ "baseline_xG_p90",
+ "baseline_CBIT_p90",
+ "baseline_CBITR_p90",
+ ]
+
+ # Filter to only stats that exist for this player
+ player_row_current_display_values = st.session_state.output_df[ # Use output_df for *current displayed* values (incl. previous overrides)
+ st.session_state.output_df["ID"] == player_id
+ ].iloc[0]
+
+ player_row_original_baselines = st.session_state.finalized_df[ # Use finalized_df for *original* baselines
+ st.session_state.finalized_df["id"] == player_id
+ ].iloc[0]
+
+ all_editable_stats = direct_override_stats + multiplier_override_stats
+ available_stats = [
+ stat
+ for stat in all_editable_stats
+ if stat in player_row_original_baselines.index
+ ]
+
+ selected_stat = st.selectbox(
+ "Select Stat to Edit", available_stats, key="stat_selector"
+ )
+
+ if selected_stat:
+ # Define a unique session state key for this specific player's selected baseline stat
+ sync_key_baseline_admin = (
+ f"sync_baseline_admin_{player_id}_{selected_stat}"
+ )
+
+ # Determine the default value for the input widget based on whether it's a direct or multiplier stat
+ if selected_stat in direct_override_stats:
+ # For direct override stats, show the currently overridden value, or the original baseline
+ default_input_value = st.session_state.user_baseline_overrides.get(
+ player_id, {}
+ ).get(selected_stat, player_row_original_baselines[selected_stat])
+ min_val, max_val, step_val = (
+ 0.0,
+ 10.0,
+ 0.1,
+ ) # Default, will be overridden below
+
+ if "xMins" in selected_stat:
+ min_val, max_val, step_val = 0.00, 90.00, 1.0
+ elif "Avg_BPS" in selected_stat:
+ min_val, max_val, step_val = 0.0, 100.0, 0.1
+ elif "pksave_p90" in selected_stat:
+ min_val, max_val, step_val = 0.0, 5.0, 0.01
+
+ # Ensure value is float
+ default_input_value = float(default_input_value)
+
+ st.slider(
+ f"Direct Value for {selected_stat} (Slider)",
+ min_value=min_val,
+ max_value=max_val,
+ value=st.session_state.get(
+ sync_key_baseline_admin, default_input_value
+ ), # Reads from synchronized state
+ step=step_val,
+ format="%.2f" if step_val < 0.1 else "%g",
+ key=f"slider_baseline_admin_{player_id}_{selected_stat}",
+ on_change=update_sync_value_from_slider,
+ args=(
+ sync_key_baseline_admin,
+ f"slider_baseline_admin_{player_id}_{selected_stat}",
+ ),
+ )
+ st.number_input(
+ f"Direct Value for {selected_stat} (Input)",
+ min_value=min_val,
+ max_value=max_val,
+ value=st.session_state.get(
+ sync_key_baseline_admin, default_input_value
+ ), # Reads from synchronized state
+ step=step_val,
+ format="%.2f" if step_val < 0.1 else "%g",
+ key=f"num_input_baseline_admin_{player_id}_{selected_stat}",
+ on_change=update_sync_value_from_number_input,
+ args=(
+ sync_key_baseline_admin,
+ f"num_input_baseline_admin_{player_id}_{selected_stat}",
+ ),
+ )
+
+ else: # Multiplier override stats
+ # For multiplier stats, show the currently set multiplier, or default to 1.0
+ default_input_value = st.session_state.user_baseline_overrides.get(
+ player_id, {}
+ ).get(selected_stat, 1.0)
+ min_val, max_val, step_val = 0.0, 5.0, 0.01
+
+ # Ensure value is float
+ default_input_value = float(default_input_value)
+
+ st.write(
+ f"Original value for {selected_stat}: {player_row_original_baselines[selected_stat]:.2f}"
+ )
+ st.slider(
+ f"Multiplier for {selected_stat} (Slider)",
+ min_value=min_val,
+ max_value=max_val,
+ value=st.session_state.get(
+ sync_key_baseline_admin, default_input_value
+ ), # Reads from synchronized state
+ step=step_val,
+ format="%.2f",
+ key=f"slider_baseline_admin_{player_id}_{selected_stat}",
+ on_change=update_sync_value_from_slider,
+ args=(
+ sync_key_baseline_admin,
+ f"slider_baseline_admin_{player_id}_{selected_stat}",
+ ),
+ )
+ st.number_input(
+ f"Multiplier for {selected_stat} (Input)",
+ min_value=min_val,
+ max_value=max_val,
+ value=st.session_state.get(
+ sync_key_baseline_admin, default_input_value
+ ), # Reads from synchronized state
+ step=step_val,
+ format="%.2f",
+ key=f"num_input_baseline_admin_{player_id}_{selected_stat}",
+ on_change=update_sync_value_from_number_input,
+ args=(
+ sync_key_baseline_admin,
+ f"num_input_baseline_admin_{player_id}_{selected_stat}",
+ ),
+ )
+ # Show the effective value if a multiplier is applied
+ current_multiplier = st.session_state.get(
+ sync_key_baseline_admin, default_input_value
+ )
+ effective_value = (
+ player_row_original_baselines[selected_stat]
+ * current_multiplier
+ )
+ st.info(f"Effective value: {effective_value:.2f}")
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button(
+ "Update Stat", key=f"update_baseline_{player_id}_{selected_stat}"
+ ):
+ if player_id not in st.session_state.user_baseline_overrides:
+ st.session_state.user_baseline_overrides[player_id] = {}
+
+ st.session_state.user_baseline_overrides[player_id][
+ selected_stat
+ ] = st.session_state[sync_key_baseline_admin]
+
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides()
+ st.success("Baseline stat updated and saved!")
+ # st.rerun() # REMOVED: Let Streamlit handle reruns naturally
+
+ if col2.button(
+ "Reset Stat", key=f"reset_baseline_{player_id}_{selected_stat}"
+ ):
+ if (
+ player_id in st.session_state.user_baseline_overrides
+ and selected_stat
+ in st.session_state.user_baseline_overrides[player_id]
+ ):
+ del st.session_state.user_baseline_overrides[player_id][
+ selected_stat
+ ]
+ if not st.session_state.user_baseline_overrides[player_id]:
+ del st.session_state.user_baseline_overrides[player_id]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides()
+ st.success("Stat reset to default and saved!")
+ # st.rerun() # REMOVED: Let Streamlit handle reruns naturally
+
+ st.divider()
+
+ # Adjust Player Penalty Share Section
+ st.subheader("Adjust Player Penalty Share (Admin)")
+
+ # Determine initial index for selected_penalty_player
+ initial_penalty_index = 0
+ if (
+ st.session_state.selected_penalty_player
+ and st.session_state.selected_penalty_player in player_display_names
+ ):
+ initial_penalty_index = player_display_names.index(
+ st.session_state.selected_penalty_player
+ )
+ elif (
+ player_display_names
+ ): # Added check to ensure player_display_names is not empty
+ st.session_state.selected_penalty_player = player_display_names[0]
+ initial_penalty_index = 0
+
+ penalty_player_selected_display = st.selectbox(
+ "Select Player for Penalty Share Editing",
+ options=player_display_names,
+ key="penalty_sidebar_player",
+ index=initial_penalty_index,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_penalty_player",
+ st.session_state.penalty_sidebar_player,
+ ),
+ )
+
+ if penalty_player_selected_display: # Use player_id from map
+ player_id = player_display_name_to_id_map.get(
+ penalty_player_selected_display
+ )
+
+ # Get current penalty share or default to 0.0
+ current_penalty_share = st.session_state.player_penalty_shares.get(
+ player_id, 0.0
+ )
+
+ new_penalty_share = st.slider(
+ "Penalty Share (0.0 to 1.0)",
+ min_value=0.0,
+ max_value=1.0,
+ value=float(current_penalty_share),
+ step=0.01,
+ key=f"penalty_slider_{player_id}",
+ )
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button("Update Penalty Share", key=f"update_penalty_{player_id}"):
+ st.session_state.player_penalty_shares[player_id] = new_penalty_share
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Automatic save
+ st.success("Penalty share updated and saved!")
+ st.rerun()
+
+ if col2.button("Reset Penalty Share", key=f"reset_penalty_{player_id}"):
+ if player_id in st.session_state.player_penalty_shares:
+ del st.session_state.player_penalty_shares[player_id]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides() # Automatic save
+ st.success("Penalty share reset to default and saved!")
+ st.rerun()
+
+ st.divider()
+
+ st.subheader("Adjust Availability Multiplier (Admin)")
+
+ # Determine initial index for selected_availability_player
+ initial_availability_index = 0
+ if (
+ st.session_state.selected_availability_player
+ and st.session_state.selected_availability_player in player_display_names
+ ):
+ initial_availability_index = player_display_names.index(
+ st.session_state.selected_availability_player
+ )
+ elif player_display_names:
+ st.session_state.selected_availability_player = player_display_names[0]
+ initial_availability_index = 0
+
+ availability_player_selected_display = st.selectbox(
+ "Select Player for Availability Multiplier",
+ options=player_display_names,
+ key="availability_sidebar_player",
+ index=initial_availability_index,
+ on_change=lambda: setattr(
+ st.session_state,
+ "selected_availability_player",
+ st.session_state.availability_sidebar_player,
+ ),
+ )
+
+ if availability_player_selected_display:
+ player_id = player_display_name_to_id_map.get(
+ availability_player_selected_display
+ )
+
+ gw_selected = st.selectbox(
+ "Select Gameweek for Availability Multiplier",
+ gw_choices,
+ key="availability_sidebar_gw",
+ )
+
+ if player_id is not None and gw_selected:
+ gw = int(gw_selected)
+
+ # Get current multiplier or default to 1.0
+ current_multiplier = (
+ st.session_state.admin_persistent_availability_multipliers.get(
+ player_id, {}
+ ).get(gw, 1.0)
+ )
+
+ new_multiplier = st.slider(
+ "Availability Multiplier",
+ min_value=0.0,
+ max_value=1.0,
+ step=0.01,
+ value=float(current_multiplier),
+ key=f"availability_slider_{player_id}_{gw}",
+ )
+
+ col1, col2 = st.columns([1, 1])
+ if col1.button(
+ "Update Multiplier", key=f"update_availability_{player_id}_{gw}"
+ ):
+ st.session_state.admin_persistent_availability_multipliers.setdefault(
+ player_id, {}
+ )[gw] = new_multiplier
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides()
+ st.success("Availability multiplier updated and saved!")
+ st.rerun()
+
+ if col2.button(
+ "Reset Multiplier", key=f"reset_availability_{player_id}_{gw}"
+ ):
+ if (
+ player_id
+ in st.session_state.admin_persistent_availability_multipliers
+ ):
+ st.session_state.admin_persistent_availability_multipliers[
+ player_id
+ ].pop(gw, None)
+ if not st.session_state.admin_persistent_availability_multipliers[
+ player_id
+ ]:
+ del st.session_state.admin_persistent_availability_multipliers[
+ player_id
+ ]
+ with st.spinner("Recalculating..."):
+ recalculate_projections()
+ save_admin_overrides()
+ st.success("Availability multiplier reset to default and saved!")
+ st.rerun()
+
+ st.divider()
+
+ # Adjust Global Rates Section
+ with st.expander("Adjust Global Decay/Ramp-up Rates (Admin)"):
+ st.write(
+ "These rates apply if a player's status doesn't have a specific rate defined."
+ )
+
+ # Decay rates
+ st.subheader("Decay Rates (Multiplicative)")
+ for status, rate in st.session_state.decay_rates.items():
+ new_rate = st.number_input(
+ f"{status.replace('_', ' ').title()} Decay Rate (e.g., 0.99 for 1% decay)",
+ min_value=0.0,
+ max_value=1.0,
+ value=float(rate),
+ step=0.01,
+ key=f"decay_rate_{status}",
+ )
+ st.session_state.decay_rates[status] = new_rate
+
+ # Ramp-up rates
+ st.subheader("Ramp-up Rates (Additive)")
+ for status, rate in st.session_state.ramp_up_rates.items():
+ new_rate = st.number_input(
+ f"{status.replace('_', ' ').title()} Ramp-up Rate (minutes per GW)",
+ min_value=0.0,
+ max_value=90.0,
+ value=float(rate),
+ step=1.0,
+ key=f"ramp_up_rate_{status}",
+ )
+ st.session_state.ramp_up_rates[status] = new_rate
+
+ # Ramp-up Period
+ new_ramp_up_period = st.number_input(
+ "Injured Player Ramp-up Period (Weeks)",
+ min_value=1,
+ max_value=10,
+ value=st.session_state.RAMP_UP_PERIOD,
+ step=1,
+ key="ramp_up_period_input",
+ )
+ st.session_state.RAMP_UP_PERIOD = new_ramp_up_period
+
+ # Mins Threshold
+ new_mins_threshold = st.number_input(
+ "Minutes Threshold (for decay vs. ramp-up)",
+ min_value=0,
+ max_value=90,
+ value=st.session_state.MINS_THRESHOLD,
+ step=1,
+ key="mins_threshold_input",
+ )
+ st.session_state.MINS_THRESHOLD = new_mins_threshold
+
+ if st.button("Apply Global Rate Changes", key="apply_global_rates"):
+ with st.spinner("Recalculating with new rates..."):
+ recalculate_projections()
+ save_admin_overrides() # Automatic save
+ st.success("Global rates updated and saved!")
+ st.rerun()
+
+ st.divider()
+
+ # Global Reset Section
+ st.subheader("Global Reset (Admin)")
+ if st.button(
+ "Reset ALL Admin Edits (Baselines, Status, Rates, Penalties, Weekly xMins)",
+ key="global_reset",
+ type="secondary",
+ ):
+ if st.session_state.is_admin_logged_in:
+ # Use st.warning for confirmation instead of window.confirm
+ st.warning(
+ "Are you sure you want to reset ALL admin-controlled edits to their default values? This action cannot be undone and will affect all future sessions."
+ )
+ # Add a separate button for actual confirmation
+ if st.button("Confirm Global Reset", key="confirm_global_reset"):
+ st.session_state.user_baseline_overrides = {}
+ st.session_state.user_player_status_overrides = {}
+ st.session_state.admin_persistent_xmins_overrides = {} # Reset admin persistent xMins
+ st.session_state.player_penalty_shares = { # Reset to default hardcoded values
+ 16: 0.65,
+ 17: 0.15,
+ 30: 0.05,
+ 666: 0.3,
+ 48: 0.4,
+ 64: 0.7,
+ 81: 0.9,
+ 97: 0.25,
+ 136: 0.8,
+ 121: 0.09,
+ 178: 0.8,
+ 158: 0.05,
+ 202: 0.25,
+ 215: 0.6,
+ 216: 0.02,
+ 235: 0.9,
+ 249: 0.1,
+ 266: 0.6,
+ 267: 0.04,
+ 283: 0.4,
+ 299: 0.85,
+ 311: 0.1,
+ 310: 0.1,
+ 337: 0.6,
+ 327: 0.55,
+ 343: 0.4,
+ 362: 0.7,
+ 381: 0.95,
+ 382: 0.1,
+ 386: 0.05,
+ 430: 0.95,
+ 413: 0.15,
+ 449: 0.9,
+ 119: 0.1,
+ 450: 0.05,
+ 499: 0.85,
+ 485: 0.2,
+ 474: 0.02,
+ 525: 0.85,
+ 515: 0.25,
+ 596: 0.9,
+ 612: 0.8,
+ 624: 0.25,
+ 625: 0.04,
+ 647: 0.1,
+ 654: 0.85,
+ }
+ # Reset rates to their absolute initial defaults (hardcoded defaults)
+ st.session_state.decay_rates = {
+ "default": 0.99,
+ "suspended": 0.95,
+ "injured_decay": 0.95,
+ "rotational_risk": 0.97,
+ }
+ st.session_state.ramp_up_rates = {
+ "default": 5,
+ "injured": 10,
+ "suspended": 5,
+ "starter": 0,
+ "rotational_risk": 0,
+ }
+ st.session_state.RAMP_UP_PERIOD = 3
+ st.session_state.MINS_THRESHOLD = 45
+
+ with st.spinner("Resetting all admin-controlled edits..."):
+ recalculate_projections()
+ save_admin_overrides() # Save the reset state to files
+ st.success("All admin-controlled edits reset to default and saved!")
+ st.rerun()
+ else:
+ st.error("You must be logged in as an admin to perform a global reset.")
+
+ st.divider()
+
+ # Show current ADMIN-controlled overrides info
+ st.subheader("Current Admin Overrides (Saved)")
+
+ if st.session_state.user_baseline_overrides:
+ st.write("**Baseline Overrides:**")
+ for pid, stat_dict in st.session_state.user_baseline_overrides.items():
+ # Use the display name from the map if available
+ player_name_display = player_id_to_display_name_map.get(
+ pid,
+ st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ]["web_name"].iloc[0]
+ if st.session_state.finalized_df is not None
+ and not st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ].empty
+ else f"ID: {pid}",
+ )
+ st.write(f"• {player_name_display}: {len(stat_dict)} stats")
+ else:
+ st.write("No saved baseline overrides.")
+
+ if st.session_state.user_player_status_overrides:
+ st.write("**Player Status Overrides:**")
+ for (
+ pid,
+ status_dict,
+ ) in st.session_state.user_player_status_overrides.items():
+ # Use the display name from the map if available
+ player_name_display = player_id_to_display_name_map.get(
+ pid,
+ st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ]["web_name"].iloc[0]
+ if st.session_state.finalized_df is not None
+ and not st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ].empty
+ else f"ID: {pid}",
+ )
+ status_str = status_dict["status"]
+ if "weeks_out" in status_dict:
+ status_str += f" (out until GW {status_dict['weeks_out']})"
+ st.write(f"• {player_name_display}: {status_str}")
+ else:
+ st.write("No saved player status overrides.")
+
+ if st.session_state.player_penalty_shares:
+ st.write("**Penalty Share Overrides:**")
+ for pid, share_value in st.session_state.player_penalty_shares.items():
+ # Use the display name from the map if available
+ player_name_display = player_id_to_display_name_map.get(
+ pid,
+ st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ]["web_name"].iloc[0]
+ if st.session_state.finalized_df is not None
+ and not st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ].empty
+ else f"ID: {pid}",
+ )
+ if not player_name_display.startswith(
+ "ID:"
+ ): # Only show name if we found it
+ st.write(f"• {player_name_display}: {share_value:.2f}")
+ else:
+ st.write(f"• Unknown Player (ID: {pid}): {share_value:.2f}")
+ else:
+ st.write("No saved penalty share overrides.")
+
+ if st.session_state.admin_persistent_xmins_overrides:
+ st.write("**Weekly xMins Overrides (Admin Saved):**")
+ for (
+ pid,
+ gw_dict,
+ ) in st.session_state.admin_persistent_xmins_overrides.items():
+ player_name_display = player_id_to_display_name_map.get(
+ pid,
+ st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ]["web_name"].iloc[0]
+ if st.session_state.finalized_df is not None
+ and not st.session_state.finalized_df[
+ st.session_state.finalized_df["id"] == pid
+ ].empty
+ else f"ID: {pid}",
+ )
+ st.write(f"• {player_name_display}: {gw_dict}")
+ else:
+ st.write("No saved weekly xMins overrides by admin.")
+
+ else:
+ st.info("Log in as admin to see and adjust advanced settings.")
+
+ # Show current LOCAL (session-only) xMins overrides info (visible to all users, but explicitly stated as temporary)
+ st.divider()
+ st.subheader("Current Local Overrides (Per Session)")
+ if st.session_state.user_xmins_overrides:
+ st.write("**Minutes Overrides (Local Session):**")
+ for pid, gw_dict in st.session_state.user_xmins_overrides.items():
+ # Use the display name from the map if available, otherwise fallback to original web_name
+ player_name_display = player_id_to_display_name_map.get(
+ pid,
+ st.session_state.output_df[st.session_state.output_df["ID"] == pid][
+ "Name"
+ ].iloc[0]
+ if st.session_state.output_df is not None
+ and not st.session_state.output_df[
+ st.session_state.output_df["ID"] == pid
+ ].empty
+ else f"ID: {pid}",
+ )
+ st.write(f"• {player_name_display}: {gw_dict}")
+ else:
+ st.write("No local xMins overrides for this session.")
+
+tab1, tab2, tab3, tab4 = st.tabs(
+ ["Projections", "Accuracy Dashboard", "Team Ratings", "Upcoming Fixtures"]
+)
+
+# --- MAIN CONTENT AREA ---
+with tab1:
+ st.subheader("Projected Player Points")
+
+ # Display the main table (read-only now since editing is in sidebar)
+ st.dataframe(
+ st.session_state.output_df,
+ hide_index=True,
+ use_container_width=True,
+ height=600,
+ )
+
+ # Download button
+ csv_data = st.session_state.output_df.to_csv(index=False).encode("utf-8")
+ st.download_button(
+ label="Download Table as CSV", # Removed emoji
+ data=csv_data,
+ file_name="luigis_mansion.csv",
+ mime="text/csv",
+ key="download_csv_button",
+ )
+
+ st.markdown("---")
+ st.markdown(
+ "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!"
+ )
+ st.markdown("**Instructions:**")
+ st.markdown(
+ "- Use the **sidebar** to adjust player minutes, weekly or across the horizon." # Updated text
+ )
+ st.markdown(
+ "- Please wait **1-2 seconds** after pressing the Update button as your changes get processed and updated."
+ )
+ st.markdown(
+ "- Currently, the overrides **CANNOT** be applied simultaneously, so don't forget to press the **Update** button else your changes will not be processed."
+ )
+ st.markdown(
+ "- The Reset Override button **ONLY** resets the xMins of the player chosen. To revert back to original projections, simply **reload** the page."
+ )
+ st.markdown(
+ "- 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."
+ )
+ st.markdown(
+ "- You can download your customized projections as a CSV file (will be downloaded as 'luigis_mansion.csv')."
+ )
+
+
+def local_css():
+ st.markdown(
+ """
+
+ """,
+ unsafe_allow_html=True,
+ )
+
+
+# Apply CSS
+local_css()
+
+
+with tab2:
+ proj_file = pd.read_excel("projections_check.xlsx")
+ pts_file = pd.read_excel("points_check.xlsx")
+
+ def calculate_metrics(y_true, y_prob):
+ brier = brier_score_loss(y_true, y_prob)
+ ll = log_loss(y_true, y_prob)
+ return brier, ll
+
+ def multiclass_brier_score(y_true_onehot, y_prob):
+ """
+ Calculates the Brier Score for multi-class classification.
+ Formula: Mean of sum of squared differences between probabilities and actuals.
+ """
+ return np.mean(np.sum((y_prob - y_true_onehot) ** 2, axis=1))
+
+ def get_player_stats(df, min_xmins=0, min_xpts=0):
+ # ... [Keep your exact existing function code] ...
+ all_xmins = []
+ all_actual_mins = []
+ all_xpts = []
+ all_actual_pts = []
+ gameweeks = range(3, 39)
+ for gw in gameweeks:
+ xmins_col = f"{gw}_xMins"
+ actual_mins_col = f"{gw}_Mins"
+ xpts_col = f"{gw}_Pts"
+ actual_pts_col = f"{gw}_Actuals"
+ if all(
+ col in df.columns
+ for col in [xmins_col, actual_mins_col, xpts_col, actual_pts_col]
+ ):
+ mask = (df[xmins_col] >= min_xmins) & (df[xpts_col] >= min_xpts)
+ all_xmins.extend(df.loc[mask, xmins_col].tolist())
+ all_actual_mins.extend(df.loc[mask, actual_mins_col].tolist())
+ all_xpts.extend(df.loc[mask, xpts_col].tolist())
+ all_actual_pts.extend(df.loc[mask, actual_pts_col].tolist())
+
+ if not all_xmins:
+ return None
+
+ # Return a DataFrame friendly format for the new UI
+ return {
+ "xMins": pd.DataFrame(
+ [
+ {
+ "Metric": "Minutes",
+ "MAE": mean_absolute_error(all_actual_mins, all_xmins),
+ "RMSE": np.sqrt(mean_squared_error(all_actual_mins, all_xmins)),
+ "R2": r2_score(all_actual_mins, all_xmins),
+ }
+ ]
+ ),
+ "xPts": pd.DataFrame(
+ [
+ {
+ "Metric": "Points",
+ "MAE": mean_absolute_error(all_actual_pts, all_xpts),
+ "RMSE": np.sqrt(mean_squared_error(all_actual_pts, all_xpts)),
+ "R2": r2_score(all_actual_pts, all_xpts),
+ }
+ ]
+ ),
+ }
+
+ # --- 3. Main Interface Tabs ---
+ st.title("Model Performance Dashboard")
+ main_tab1, main_tab2, main_tab3 = st.tabs(
+ ["Outcome Accuracy", "Goals & xG", "xMins & xPts"]
+ )
+
+ # ==========================================
+ # TAB 1: OUTCOME ACCURACY
+ # ==========================================
+ with main_tab1:
+ tab1, tab2 = st.tabs(["Model Performance", "Trend"])
+
+ with tab1:
+ st.warning(
+ "Note: Data excludes GW1 & GW2 projections (I forgot to save the projections :sweat_smile:)."
+ )
+
+ # --- 1. PREPARE DATA FOR MULTI-CLASS METRICS ---
+ # Create a matrix of probabilities
+ probs_matrix = proj_file[
+ ["home_win_prob", "draw_prob", "away_win_prob"]
+ ].values
+
+ # Create a One-Hot encoded matrix of actual results
+ # We need to map Home/Draw/Away cols to a single matrix
+ # Let's stack them: Column 0=Home, 1=Draw, 2=Away
+ actuals_matrix = proj_file[["home_win", "draw", "away_win"]].values
+
+ # Create a single label array for sklearn log_loss (0, 1, 2)
+ # argmax gives us the index of the column that has a '1'
+ y_true_labels = actuals_matrix.argmax(axis=1)
+
+ # --- 2. CALCULATE GLOBAL METRICS ---
+ global_ll = log_loss(y_true_labels, probs_matrix)
+ global_brier = multiclass_brier_score(actuals_matrix, probs_matrix)
+
+ # --- NEW: CALCULATE GLOBAL RPS ---
+ # We calculate it vector-style for the whole dataframe at once
+ # 1. Prediction CDFs
+ p_h = proj_file["home_win_prob"].values
+ p_d = proj_file["draw_prob"].values
+ cdf_pred_1 = p_h
+ cdf_pred_2 = p_h + p_d
+
+ # 2. Observation CDFs (Actuals)
+ # Note: Ensure we use the actual result columns, not probabilities
+ o_h = proj_file["home_win"].values
+ o_d = proj_file["draw"].values
+ cdf_obs_1 = o_h
+ cdf_obs_2 = o_h + o_d
+
+ # 3. Mean RPS
+ rps_per_match = 0.5 * (
+ (cdf_pred_1 - cdf_obs_1) ** 2 + (cdf_pred_2 - cdf_obs_2) ** 2
+ )
+ global_rps = np.mean(rps_per_match)
+
+ st.markdown("### Model Performance")
+ st.caption(
+ "These metrics evaluate the entire match probability distribution (Home/Draw/Away) at once."
+ )
+
+ # Display Headline Metrics (Updated to 3 columns)
+ head1, head2, head3 = st.columns(3)
+
+ with head1:
+ st.metric(
+ "Multi-class Log Loss",
+ f"{global_ll:.4f}",
+ )
+ with head2:
+ st.metric(
+ "Multi-class Brier Score",
+ f"{global_brier:.4f}",
+ )
+ with head3:
+ st.metric(
+ "Ranked Probability Score",
+ f"{global_rps:.4f}",
+ )
+
+ st.divider()
+
+ # --- 3. BREAKDOWN METRICS (Keep these for debugging) ---
+ st.markdown("### Outcome Breakdown")
+ st.caption(
+ "Diagnostic metrics to see which specific outcome the model struggles with."
+ )
+
+ col1, col2, col3 = st.columns(3)
+
+ # Home
+ b_home, ll_home = calculate_metrics(
+ proj_file["home_win"], proj_file["home_win_prob"]
+ )
+ with col1:
+ st.markdown("#### Home Win")
+ st.metric("Binary Brier", f"{b_home:.4f}")
+ st.metric("Binary Log Loss", f"{ll_home:.4f}")
+
+ # Draw
+ b_draw, ll_draw = calculate_metrics(
+ proj_file["draw"], proj_file["draw_prob"]
+ )
+ with col2:
+ st.markdown("#### Draw")
+ st.metric("Binary Brier", f"{b_draw:.4f}")
+ st.metric("Binary Log Loss", f"{ll_draw:.4f}")
+
+ # Away
+ b_away, ll_away = calculate_metrics(
+ proj_file["away_win"], proj_file["away_win_prob"]
+ )
+ with col3:
+ st.markdown("#### Away Win")
+ st.metric("Binary Brier", f"{b_away:.4f}")
+ st.metric("Binary Log Loss", f"{ll_away:.4f}")
+
+ st.divider()
+
+ # Row 3: Clean Sheets (No changes needed here as CS is binary)
+ actual_cs = pd.concat(
+ [proj_file["home_clean_sheet"], proj_file["away_clean_sheet"]],
+ ignore_index=True,
+ )
+ pred_cs = pd.concat(
+ [
+ proj_file["home_clean_sheet_odds"],
+ proj_file["away_clean_sheet_odds"],
+ ],
+ ignore_index=True,
+ )
+ b_cs, ll_cs = calculate_metrics(actual_cs, pred_cs)
+
+ c1, c2 = st.columns([1, 3])
+ with c1:
+ st.markdown("#### Clean Sheets")
+ st.metric("CS Brier", f"{b_cs:.4f}")
+ st.metric("CS Log Loss", f"{ll_cs:.4f}")
+ with c2:
+ fig_hist = px.histogram(
+ pred_cs, nbins=20, title="Distribution of CS Probabilities"
+ )
+ fig_hist.update_layout(
+ height=300,
+ margin=dict(l=20, r=20, t=30, b=20),
+ plot_bgcolor="rgba(0,0,0,0.5)",
+ paper_bgcolor="rgba(0,0,0,0.5)",
+ )
+ st.plotly_chart(fig_hist, use_container_width=True)
+
+ with tab2:
+ st.warning(
+ "Note: Data excludes GW1 & GW2 projections (I forgot to save the projections :sweat_smile:)."
+ )
+ # --- 1. Prepare Data by Gameweek ---
+ # We need to extract the Gameweek from the column names or if you have a 'GW' column
+ # Since your data structure seems to be wide (one row per match), we assume there's a 'GW' column.
+ # If not, we can infer it or you might need to ensure your excel has a 'GW' column.
+
+ # CHECK: Does your dataframe have a 'GW' or 'Gameweek' column?
+ # If not, add this line to your data loading step:
+ # proj_file['GW'] = proj_file['gameweek'] # or whatever your column is named
+ ll, bs, rps = st.tabs(
+ ["Log Loss", "Brier Score", "Ranked Probability Score (RPS)"]
+ )
+ with ll:
+ if "GW" in proj_file.columns:
+ # Group by Gameweek
+ gw_metrics = []
+
+ for gw in sorted(proj_file["GW"].unique()):
+ gw_data = proj_file[proj_file["GW"] == gw]
+
+ # Get matrices for this specific GW
+ probs_gw = gw_data[
+ ["home_win_prob", "draw_prob", "away_win_prob"]
+ ].values
+ actuals_gw = gw_data[["home_win", "draw", "away_win"]].values
+ y_true_gw = actuals_gw.argmax(axis=1)
+
+ # Calculate Weekly Log Loss
+ ll_weekly = log_loss(y_true_gw, probs_gw, labels=[0, 1, 2])
+
+ gw_metrics.append(
+ {
+ "Gameweek": gw,
+ "Weekly Log Loss": ll_weekly,
+ "Match Count": len(gw_data),
+ }
+ )
+
+ trend_df = pd.DataFrame(gw_metrics)
+
+ # Calculate Cumulative Log Loss (The "Real" Score)
+ # We need to reconstruct the full history to do this accurately
+ cumulative_scores = []
+ all_probs = []
+ all_true = []
+
+ for gw in sorted(proj_file["GW"].unique()):
+ gw_data = proj_file[proj_file["GW"] == gw]
+ all_probs.append(
+ gw_data[
+ ["home_win_prob", "draw_prob", "away_win_prob"]
+ ].values
+ )
+ all_true.append(
+ gw_data[["home_win", "draw", "away_win"]].values.argmax(
+ axis=1
+ )
+ )
+
+ # Stack all data up to this point
+ curr_probs = np.vstack(all_probs)
+ curr_true = np.concatenate(all_true)
+
+ cum_ll = log_loss(curr_true, curr_probs, labels=[0, 1, 2])
+ cumulative_scores.append(cum_ll)
+
+ trend_df["Cumulative Log Loss"] = cumulative_scores
+
+ # --- 2. Plotting ---
+ # Create a dual-line chart
+ fig_trend = go.Figure()
+
+ # Line 1: Weekly Volatility (Faint)
+ fig_trend.add_trace(
+ go.Scatter(
+ x=trend_df["Gameweek"],
+ y=trend_df["Weekly Log Loss"],
+ mode="lines+markers",
+ name="Weekly Score",
+ line=dict(color="#10f770", width=1, dash="dot"),
+ hovertemplate="GW%{x}: %{y:.3f} ",
+ )
+ )
+
+ # Line 2: Cumulative Trend (Strong)
+ fig_trend.add_trace(
+ go.Scatter(
+ x=trend_df["Gameweek"],
+ y=trend_df["Cumulative Log Loss"],
+ mode="lines+markers",
+ name="Cumulative Trend",
+ line=dict(color="#FF4B4B", width=3),
+ hovertemplate="Avg up to GW%{x}: %{y:.3f} ",
+ )
+ )
+
+ # Add Benchmark Lines (The Context)
+ fig_trend.add_hline(
+ y=1.0986,
+ line_dash="dot",
+ line_color="yellow",
+ annotation_text="Naive Model (1.06)",
+ annotation_position="top right",
+ )
+ fig_trend.add_hline(
+ y=0.98,
+ line_dash="dot",
+ line_color="#2cacdf",
+ annotation_text="Bookie Closing (0.98)",
+ annotation_position="bottom right",
+ )
+
+ # Styling to match your transparent theme
+ fig_trend.update_layout(
+ title="Log Loss Trend",
+ xaxis_title="Gameweek",
+ yaxis_title="Log Loss (Lower values are better)",
+ height=900,
+ hovermode="x unified",
+ plot_bgcolor="rgba(0,0,0,0.8)",
+ paper_bgcolor="rgba(0,0,0,0.8)",
+ yaxis=dict(
+ autorange="reversed"
+ ), # Invert Y axis so "Lower" (Better) is higher up visually?
+ # Actually, standard is usually 0 at bottom. Let's keep standard but remember lower is better.
+ )
+ # Note: I didn't invert Y-axis because it can be confusing, but keep in mind the trend should go DOWN.
+ st.plotly_chart(fig_trend, use_container_width=True)
+
+ # Insight Text
+ current_trend = trend_df["Cumulative Log Loss"].iloc[-1]
+ start_trend = trend_df["Cumulative Log Loss"].iloc[0]
+ improvement = start_trend - current_trend
+
+ if improvement > 0:
+ st.success(
+ f"Model has improved by {improvement:.4f} since the start of tracking."
+ )
+ else:
+ st.info(
+ f"Mdel performance has degraded slightly ({improvement:.4f})."
+ )
+
+ with bs:
+ brier_metrics = []
+ cumulative_probs = []
+ cumulative_actuals = []
+
+ # Sort by Gameweek to ensure correct cumulative calculation
+ sorted_gws = sorted(proj_file["GW"].unique())
+
+ for gw in sorted_gws:
+ gw_data = proj_file[proj_file["GW"] == gw]
+
+ # Extract matrices
+ probs_gw = gw_data[
+ ["home_win_prob", "draw_prob", "away_win_prob"]
+ ].values
+ # One-hot encode the actuals (Home/Draw/Away columns)
+ actuals_gw_onehot = gw_data[["home_win", "draw", "away_win"]].values
+
+ # A. Weekly Brier Score
+ # Formula: Mean of sum of squared differences for all 3 outcomes
+ brier_weekly = np.mean(
+ np.sum((probs_gw - actuals_gw_onehot) ** 2, axis=1)
+ )
+
+ # B. Update Cumulative Lists
+ cumulative_probs.append(probs_gw)
+ cumulative_actuals.append(actuals_gw_onehot)
+
+ # C. Calculate Cumulative Brier (The "Real" Score)
+ curr_probs_stack = np.vstack(cumulative_probs)
+ curr_actuals_stack = np.vstack(cumulative_actuals)
+
+ brier_cum = np.mean(
+ np.sum((curr_probs_stack - curr_actuals_stack) ** 2, axis=1)
+ )
+
+ brier_metrics.append(
+ {
+ "Gameweek": gw,
+ "Weekly Brier": brier_weekly,
+ "Cumulative Brier": brier_cum,
+ }
+ )
+
+ brier_df = pd.DataFrame(brier_metrics)
+
+ # --- 2. Plotting ---
+ fig_brier = go.Figure()
+
+ # Line 1: Weekly Volatility (Faint)
+ fig_brier.add_trace(
+ go.Scatter(
+ x=brier_df["Gameweek"],
+ y=brier_df["Weekly Brier"],
+ mode="lines+markers",
+ name="Weekly Score",
+ line=dict(color="#ec0ba9", width=1, dash="dot"),
+ hovertemplate="GW%{x}: %{y:.4f} ",
+ )
+ )
+
+ # Line 2: Cumulative Trend (Strong Blue)
+ fig_brier.add_trace(
+ go.Scatter(
+ x=brier_df["Gameweek"],
+ y=brier_df["Cumulative Brier"],
+ mode="lines+markers",
+ name="Cumulative Trend",
+ line=dict(color="#0068C9", width=3),
+ hovertemplate="Avg up to GW%{x}: %{y:.4f} ",
+ )
+ )
+
+ # Add Reference Line (Random Guess)
+ # Random guess (0.33/0.33/0.33) results in a Brier of ~0.667
+ fig_brier.add_hline(
+ y=0.667,
+ line_dash="dot",
+ line_color="yellow",
+ annotation_text="Naive Model (0.667)",
+ annotation_position="bottom right",
+ )
+
+ # Styling
+ fig_brier.update_layout(
+ title="Multi-class Brier Score History",
+ xaxis_title="Gameweek",
+ yaxis_title="Brier Score (Lower values are better)",
+ height=900,
+ hovermode="x unified",
+ plot_bgcolor="rgba(0,0,0,0.8)",
+ paper_bgcolor="rgba(0,0,0,0.8)",
+ yaxis=dict(autorange="reversed"),
+ )
+
+ st.plotly_chart(fig_brier, use_container_width=True)
+
+ # Insight Logic
+ curr_brier = brier_df["Cumulative Brier"].iloc[-1]
+ if curr_brier < 0.60:
+ st.success(
+ f"Cumulative Brier Score ({curr_brier:.3f}) is well below the random baseline."
+ )
+ elif curr_brier < 0.66:
+ st.success(
+ f"Beating the naive model with the current Brier Score ({curr_brier:.3f})"
+ )
+ else:
+ st.success(
+ f"Score ({curr_brier:.4f}) is close to or worse than random guessing."
+ )
+
+ with rps:
+ rps_metrics = []
+ cumulative_rps_sum = 0
+ match_count = 0
+
+ sorted_gws = sorted(proj_file["GW"].unique())
+
+ for gw in sorted_gws:
+ gw_data = proj_file[proj_file["GW"] == gw]
+
+ # Extract Probs
+ p_home = gw_data["home_win_prob"].values
+ p_draw = gw_data["draw_prob"].values
+ # We don't technically need p_away for the calculation, just H and D for the CDFs
+
+ # Extract Actuals (1 or 0)
+ obs_home = gw_data["home_win"].values
+ obs_draw = gw_data[
+ "draw_prob"
+ ].values # Wait, this should be actual outcome, not prob
+ # Let's fix the extraction of actuals to be safe
+ obs_home = gw_data["home_win"].values
+ obs_draw = gw_data["draw"].values
+
+ # --- CALCULATE RPS FOR THIS BATCH ---
+ # RPS Formula for 3 outcomes (Home, Draw, Away):
+ # RPS = 0.5 * [ (CDF_pred_1 - CDF_obs_1)^2 + (CDF_pred_2 - CDF_obs_2)^2 ]
+
+ # 1. Cumulative Distribution Functions (CDF)
+ cdf_pred_1 = p_home
+ cdf_pred_2 = p_home + p_draw
+
+ cdf_obs_1 = obs_home
+ cdf_obs_2 = obs_home + obs_draw
+
+ # 2. Sum of squared differences
+ rps_per_match = 0.5 * (
+ (cdf_pred_1 - cdf_obs_1) ** 2 + (cdf_pred_2 - cdf_obs_2) ** 2
+ )
+
+ # Weekly Stats
+ rps_weekly_avg = np.mean(rps_per_match)
+
+ # Cumulative Stats
+ cumulative_rps_sum += np.sum(rps_per_match)
+ match_count += len(gw_data)
+ rps_cumulative_avg = cumulative_rps_sum / match_count
+
+ rps_metrics.append(
+ {
+ "Gameweek": gw,
+ "Weekly RPS": rps_weekly_avg,
+ "Cumulative RPS": rps_cumulative_avg,
+ }
+ )
+
+ rps_df = pd.DataFrame(rps_metrics)
+
+ # --- 2. Plotting ---
+ fig_rps = go.Figure()
+
+ # Line 1: Weekly Volatility (Faint Green)
+ fig_rps.add_trace(
+ go.Scatter(
+ x=rps_df["Gameweek"],
+ y=rps_df["Weekly RPS"],
+ mode="lines+markers",
+ name="Weekly RPS",
+ line=dict(color="#e68f0e", width=1, dash="dot"),
+ hovertemplate="GW%{x}: %{y:.4f} ",
+ )
+ )
+
+ # Line 2: Cumulative Trend (Solid Green)
+ fig_rps.add_trace(
+ go.Scatter(
+ x=rps_df["Gameweek"],
+ y=rps_df["Cumulative RPS"],
+ mode="lines+markers",
+ name="Cumulative Trend",
+ line=dict(color="#09B4AB", width=3), # SeaGreen
+ hovertemplate="Avg up to GW%{x}: %{y:.4f} ",
+ )
+ )
+
+ # Add Reference Line (Random Guess)
+ # Random guess (0.33/0.33/0.33) results in RPS ~0.27
+ fig_rps.add_hline(
+ y=0.27,
+ line_dash="dot",
+ line_color="yellow",
+ annotation_text="Naive Model (~0.27)",
+ annotation_position="bottom right",
+ )
+
+ # Styling
+ fig_rps.update_layout(
+ title="RPS History",
+ xaxis_title="Gameweek",
+ yaxis_title="RPS (Lower values are Better)",
+ height=900,
+ hovermode="x unified",
+ plot_bgcolor="rgba(0,0,0,0.8)",
+ paper_bgcolor="rgba(0,0,0,0.8)",
+ yaxis=dict(autorange="reversed"),
+ )
+
+ st.plotly_chart(fig_rps, use_container_width=True)
+
+ # Insight Logic
+ curr_rps = rps_df["Cumulative RPS"].iloc[-1]
+
+ # Benchmarks for RPS
+ if curr_rps < 0.20:
+ st.success(
+ f"RPS ({curr_rps:.4f}) is exceptionally low, superb accuracy."
+ )
+ elif curr_rps < 0.22:
+ st.success(
+ f"RPS ({curr_rps:.4f}) is above average, decentish performance."
+ )
+ else:
+ st.success(
+ f"RPS ({curr_rps:.4f}) is approaching random guessing territory (~0.27)."
+ )
+ # ==========================================
+ # TAB 2: GOALS & xG ACCURACY
+ # ==========================================
+ with main_tab2:
+ st.warning(
+ "Note: Data excludes GW1 & GW2 projections (I forgot to save the projections :sweat_smile:)."
+ )
+
+ # Prepare Data
+ actual_goals_all = pd.concat(
+ [proj_file["home_goals"], proj_file["away_goals"]], ignore_index=True
+ )
+ expected_goals_all = pd.concat(
+ [proj_file["expected_home_goals"], proj_file["expected_away_goals"]],
+ ignore_index=True,
+ )
+ xg_all = pd.concat(
+ [proj_file["xg_home"], proj_file["xg_away"]], ignore_index=True
+ )
+
+ # Create a clean DF for plotting
+ plot_df = pd.DataFrame(
+ {
+ "Actual Goals": actual_goals_all,
+ "Predicted Goals": expected_goals_all,
+ "xG Generated": xg_all,
+ }
+ )
+
+ col_g1, col_g2 = st.columns(2)
+
+ # -- Left Side: Projections vs Actuals --
+ with col_g1:
+ st.subheader("Goals v/s Projected Goals")
+ rmse_goals = np.sqrt(
+ mean_squared_error(actual_goals_all, expected_goals_all)
+ )
+ mae_goals = mean_absolute_error(actual_goals_all, expected_goals_all)
+
+ # Metrics Row
+ m1, m2 = st.columns(2)
+ m1.metric("RMSE", f"{rmse_goals:.3f}")
+ m2.metric("MAE", f"{mae_goals:.3f}")
+
+ # Interactive Scatter Plot
+ fig_goals = px.scatter(
+ plot_df,
+ x="Predicted Goals",
+ y="Actual Goals",
+ trendline="ols",
+ title="Goals v/s Projected Goals",
+ opacity=0.5,
+ )
+ fig_goals.update_layout(
+ height=400,
+ plot_bgcolor="rgba(0,0,0,0.8)",
+ paper_bgcolor="rgba(0,0,0,0.8)",
+ )
+ st.plotly_chart(
+ fig_goals, use_container_width=True, config={"staticPlot": False}
+ )
+
+ # -- Right Side: Projections vs xG --
+ with col_g2:
+ st.subheader("xG v/s Projected Goals")
+ rmse_xg = np.sqrt(mean_squared_error(xg_all, expected_goals_all))
+ mae_xg = mean_absolute_error(xg_all, expected_goals_all)
+
+ m1, m2 = st.columns(2)
+ m1.metric("RMSE", f"{rmse_xg:.3f}")
+ m2.metric("MAE", f"{mae_xg:.3f}")
+
+ # Interactive Scatter Plot
+ fig_xg = px.scatter(
+ plot_df,
+ x="Predicted Goals",
+ y="xG Generated",
+ trendline="ols",
+ title="xG v/s Projected Goals",
+ color_discrete_sequence=["#FF4B4B"],
+ opacity=0.5,
+ )
+ fig_xg.update_layout(
+ height=400,
+ plot_bgcolor="rgba(0,0,0,0.8)",
+ paper_bgcolor="rgba(0,0,0,0.8)",
+ )
+ st.plotly_chart(fig_xg, use_container_width=True)
+
+ # ==========================================
+ # TAB 3: xMINS & xPTS ACCURACY
+ # ==========================================
+ with main_tab3:
+ st.warning(
+ "Note: Data excludes GW1 & GW2 projections (I forgot to save the projections :sweat_smile:)."
+ )
+
+ # Helper to render the fancy dataframe
+ def render_stats_display(stats_dict):
+ if stats_dict:
+ c1, c2 = st.columns(2)
+
+ # We combine them into one dataframe for a cleaner look or keep separate
+ # Let's keep separate but use column_config
+
+ with c1:
+ st.markdown("#### xMins Accuracy")
+ st.dataframe(
+ stats_dict["xMins"],
+ hide_index=True,
+ use_container_width=True,
+ column_config={
+ "R2": st.column_config.ProgressColumn(
+ "R2 Score",
+ help="R-Squared Score (Higher is better)",
+ format="%.3f",
+ min_value=-1,
+ max_value=1,
+ ),
+ "MAE": st.column_config.NumberColumn("MAE", format="%.3f"),
+ "RMSE": st.column_config.NumberColumn(
+ "RMSE", format="%.3f"
+ ),
+ },
+ )
+
+ with c2:
+ st.markdown("#### xPts Accuracy")
+ st.dataframe(
+ stats_dict["xPts"],
+ hide_index=True,
+ use_container_width=True,
+ column_config={
+ "R2": st.column_config.ProgressColumn(
+ "R2 Score",
+ help="R-Squared Score (Higher is better)",
+ format="%.3f",
+ min_value=-1,
+ max_value=1,
+ ),
+ "MAE": st.column_config.NumberColumn("MAE", format="%.3f"),
+ "RMSE": st.column_config.NumberColumn(
+ "RMSE", format="%.3f"
+ ),
+ },
+ )
+ else:
+ st.error("No data found for the defined gameweeks.")
+
+ sub_tab_nolimit, sub_tab_nonzero = st.tabs(
+ ["Show All Players", "Active Players Only (1+ xMins)"]
+ )
+
+ with sub_tab_nolimit:
+ st.caption("Including bench/injured/unavailable/non-starter players.")
+ stats_no_limit = get_player_stats(pts_file, min_xmins=0)
+ render_stats_display(stats_no_limit)
+
+ with sub_tab_nonzero:
+ st.caption("Excluding players with 0 projected xMins.")
+ stats_limit = get_player_stats(pts_file, min_xmins=1)
+ render_stats_display(stats_limit)
+
+with tab3:
+ st.subheader("Team Ratings Overview")
+ items = [
+ "The Attack rating is the **projected goals for** versus an average team (does not account for home advantage).",
+ "The Defence rating is the **projected goals against** versus an average team (does not account for home advantage).",
+ "Diff is just the difference between the Attack and Defence ratings.",
+ ]
+ bulleted_string = "\n\n".join([f":arrow_right: {item}" for item in items])
+ st.info(bulleted_string)
+
+ @st.cache_data(ttl=3600) # Cache for 24 hours
+ def get_image_base64(file_path):
+ """Cache team logo encoding - significant speedup"""
+ if os.path.exists(file_path):
+ with open(file_path, "rb") as f:
+ data = f.read()
+ encoded = base64.b64encode(data).decode()
+ return f"data:image/png;base64,{encoded}"
+ return None
+
+ df = load_team_ratings_cached()
+ df.index = df.index + 1
+
+ # 1. COMPLETE MAP OF TEAM NAMES TO RELIABLE LOGO URLs
+ # Using crests.football-data.org IDs which are stable and reliable.
+ team_ids = {
+ "Arsenal": 57,
+ "Manchester City": 65,
+ "Liverpool": 64,
+ "Chelsea": 61,
+ "Newcastle United": 67,
+ "Aston Villa": 58,
+ "Manchester United": 66,
+ "Brentford": 402,
+ "Brighton and Hove Albion": 397,
+ "AFC Bournemouth": 1044,
+ "Tottenham Hotspur": 73,
+ "Crystal Palace": 354,
+ "Fulham": 63,
+ "Nottingham Forest": 351,
+ "Everton": 62,
+ "Leeds United": 341,
+ "West Ham United": 563,
+ "Wolverhampton Wanderers": 76,
+ "Sunderland": 71,
+ "Burnley": 328,
+ }
+
+ # Create the URL column dynamically
+ # We use .get() to avoid errors if a team name has a typo, and fallback to None
+ df["TeamID"] = df["Team"].map(team_ids)
+
+ # Generate the Base64 strings for the images
+ df["Logo"] = df["TeamID"].apply(
+ lambda x: get_image_base64(f"logos/{int(x)}.png") if pd.notnull(x) else None
+ )
+
+ tab_plot, tab_data = st.tabs(["Scatter Plot", "Data Table"])
+
+ with tab_plot:
+ # Base Chart Definitions
+ base = alt.Chart(df).encode(
+ x=alt.X(
+ "Defence",
+ scale=alt.Scale(reverse=True, domain=[0.6, 1.9], clamp=True),
+ axis=alt.Axis(title="Better Defence ⮕)", tickCount=5, titlePadding=40),
+ ),
+ y=alt.Y(
+ "Attack",
+ scale=alt.Scale(domain=[0.6, 2.0]),
+ axis=alt.Axis(title="Better Attack ⮕)", tickCount=5, titlePadding=50),
+ ),
+ tooltip=["Team", "Attack", "Defence", "Diff"],
+ )
+
+ # Layer 1: The Team Logos
+ images = base.mark_image(width=40, height=50).encode(url="Logo")
+
+ # Layer 2: Fallback Dots (just in case a logo is missing)
+
+ # Combine layers and increase chart size
+ chart = (
+ (images)
+ .interactive()
+ .properties(
+ height=660,
+ padding={"left": 20, "top": 20, "right": 20, "bottom": 20},
+ )
+ )
+
+ st.altair_chart(chart, use_container_width=True)
+
+ with tab_data:
+ st.dataframe(
+ df[["Team", "Attack", "Defence", "Diff"]],
+ height=740,
+ use_container_width=True,
+ )
+
+with tab4:
+ st.header("Projections for the upcoming fixtures")
+
+ def get_fresh_data():
+ # Load the file
+ df = load_fixture_projections_cached()
+
+ # Ensure no whitespace issues in headers
+ df.columns = df.columns.str.strip()
+
+ # Select top 10 rows
+ df_subset = df.head(10)
+ return df_subset
+
+ df = get_fresh_data()
+
+ # We use the original column names (keys) but map them to nice labels
+ df["home_win_prob"] = df["home_win_prob"] * 100
+ df["away_win_prob"] = df["away_win_prob"] * 100
+ df["draw_prob"] = df["draw_prob"] * 100
+
+ df["home_clean_sheet_odds"] = df["home_clean_sheet_odds"] * 100
+ df["away_clean_sheet_odds"] = df["away_clean_sheet_odds"] * 100
+
+ st.dataframe(
+ df,
+ column_order=[
+ "home_team",
+ "home_win_prob",
+ "draw_prob",
+ "away_win_prob",
+ "away_team",
+ "expected_home_goals",
+ "expected_away_goals",
+ "home_clean_sheet_odds",
+ "away_clean_sheet_odds",
+ ],
+ column_config={
+ "home_team": "Home Team",
+ "away_team": "Away Team",
+ "home_win_prob": st.column_config.ProgressColumn(
+ "Home Win",
+ format="%.1f%%", # Display as percentage
+ min_value=0,
+ max_value=100,
+ ),
+ "draw_prob": st.column_config.ProgressColumn(
+ "Draw",
+ format="%.1f%%",
+ min_value=0,
+ max_value=100,
+ ),
+ "away_win_prob": st.column_config.ProgressColumn(
+ "Away Win",
+ format="%.1f%%",
+ min_value=0,
+ max_value=100,
+ ),
+ "expected_home_goals": st.column_config.NumberColumn(
+ "Home xG", format="%.2f"
+ ),
+ "expected_away_goals": st.column_config.NumberColumn(
+ "Away xG", format="%.2f"
+ ),
+ "home_clean_sheet_odds": st.column_config.ProgressColumn(
+ "Home CS%",
+ format="%.1f%%",
+ min_value=0,
+ max_value=100,
+ ), # Format 0.3 as 30%
+ "away_clean_sheet_odds": st.column_config.ProgressColumn(
+ "Away CS%",
+ format="%.1f%%",
+ min_value=0,
+ max_value=100,
+ ),
+ },
+ hide_index=True,
+ use_container_width=True,
+ height=438,
+ row_height=40,
+ )
diff --git a/frontend/.gitignore b/frontend/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1
--- /dev/null
+++ b/frontend/.gitignore
@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..a36934d874c7fbc51aecd1c66dffc106f60693a9
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,16 @@
+# React + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
+
+## React Compiler
+
+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).
+
+## Expanding the ESLint configuration
+
+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.
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..4fa125da29e01fa85529cfa06a83a7c0ce240d55
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,29 @@
+import js from '@eslint/js'
+import globals from 'globals'
+import reactHooks from 'eslint-plugin-react-hooks'
+import reactRefresh from 'eslint-plugin-react-refresh'
+import { defineConfig, globalIgnores } from 'eslint/config'
+
+export default defineConfig([
+ globalIgnores(['dist']),
+ {
+ files: ['**/*.{js,jsx}'],
+ extends: [
+ js.configs.recommended,
+ reactHooks.configs.flat.recommended,
+ reactRefresh.configs.vite,
+ ],
+ languageOptions: {
+ ecmaVersion: 2020,
+ globals: globals.browser,
+ parserOptions: {
+ ecmaVersion: 'latest',
+ ecmaFeatures: { jsx: true },
+ sourceType: 'module',
+ },
+ },
+ rules: {
+ 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
+ },
+ },
+])
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000000000000000000000000000000000000..2e7493e1fc7f24eac088df99a17b2143c96ea771
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Luigi's Mansion
+
+
+
+
+
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
new file mode 100644
index 0000000000000000000000000000000000000000..aef9e98101ee6c9b56780a5a470632d45e1fc6a1
--- /dev/null
+++ b/frontend/package-lock.json
@@ -0,0 +1,4340 @@
+{
+ "name": "frontend",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "frontend",
+ "version": "0.0.0",
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@react-oauth/google": "^0.13.4",
+ "@tailwindcss/postcss": "^4.2.2",
+ "@tanstack/react-table": "^8.21.3",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.38.0",
+ "lucide-react": "^1.6.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "recharts": "^3.8.1",
+ "tailwind-merge": "^3.5.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "autoprefixer": "^10.4.27",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "postcss": "^8.5.8",
+ "tailwindcss": "^3.4.19",
+ "vite": "^8.0.1"
+ }
+ },
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
+ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
+ "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
+ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-compilation-targets": "^7.28.6",
+ "@babel/helper-module-transforms": "^7.28.6",
+ "@babel/helpers": "^7.28.6",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/traverse": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.1",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
+ "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@babel/types": "^7.29.0",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
+ "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.28.6",
+ "@babel/helper-validator-option": "^7.27.1",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
+ "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
+ "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.28.6",
+ "@babel/helper-validator-identifier": "^7.28.5",
+ "@babel/traverse": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
+ "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
+ "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.2",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz",
+ "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
+ "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.28.6",
+ "@babel/parser": "^7.28.6",
+ "@babel/types": "^7.28.6"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
+ "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.0",
+ "@babel/generator": "^7.29.0",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.29.0",
+ "@babel/template": "^7.28.6",
+ "@babel/types": "^7.29.0",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@dnd-kit/accessibility": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
+ "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/core": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
+ "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/accessibility": "^3.1.1",
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/sortable": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
+ "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
+ "license": "MIT",
+ "dependencies": {
+ "@dnd-kit/utilities": "^3.2.2",
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "@dnd-kit/core": "^6.3.0",
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@dnd-kit/utilities": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
+ "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz",
+ "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz",
+ "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz",
+ "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.9.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz",
+ "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz",
+ "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.1.1",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/eslintrc/node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz",
+ "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.1",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
+ "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.7",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
+ "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.1",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz",
+ "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@nodelib/fs.scandir": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+ "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "2.0.5",
+ "run-parallel": "^1.1.9"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.stat": {
+ "version": "2.0.5",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+ "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@nodelib/fs.walk": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+ "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.scandir": "2.1.5",
+ "fastq": "^1.6.0"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.122.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz",
+ "integrity": "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@react-oauth/google": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.4.tgz",
+ "integrity": "sha512-hGKyNEH+/PK8M0sFEuo3MAEk0txtHpgs94tDQit+s2LXg7b6z53NtzHfqDvoB2X8O6lGB+FRg80hY//X6hfD+w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.11.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
+ "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^11.0.0",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@reduxjs/toolkit/node_modules/immer": {
+ "version": "11.1.4",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
+ "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.11.tgz",
+ "integrity": "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.11.tgz",
+ "integrity": "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.11.tgz",
+ "integrity": "sha512-39Ks6UvIHq4rEogIfQBoBRusj0Q0nPVWIvqmwBLaT6aqQGIakHdESBVOPRRLacy4WwUPIx4ZKzfZ9PMW+IeyUQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.11.tgz",
+ "integrity": "sha512-jfsm0ZHfhiqrvWjJAmzsqiIFPz5e7mAoCOPBNTcNgkiid/LaFKiq92+0ojH+nmJmKYkre4t71BWXUZDNp7vsag==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.11.tgz",
+ "integrity": "sha512-zjQaUtSyq1nVe3nxmlSCuR96T1LPlpvmJ0SZy0WJFEsV4kFbXcq2u68L4E6O0XeFj4aex9bEauqjW8UQBeAvfQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.11.tgz",
+ "integrity": "sha512-WMW1yE6IOnehTcFE9eipFkm3XN63zypWlrJQ2iF7NrQ9b2LDRjumFoOGJE8RJJTJCTBAdmLMnJ8uVitACUUo1Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.11.tgz",
+ "integrity": "sha512-jfndI9tsfm4APzjNt6QdBkYwre5lRPUgHeDHoI7ydKUuJvz3lZeCfMsI56BZj+7BYqiKsJm7cfd/6KYV7ubrBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.11.tgz",
+ "integrity": "sha512-ZlFgw46NOAGMgcdvdYwAGu2Q+SLFA9LzbJLW+iyMOJyhj5wk6P3KEE9Gct4xWwSzFoPI7JCdYmYMzVtlgQ+zfw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.11.tgz",
+ "integrity": "sha512-hIOYmuT6ofM4K04XAZd3OzMySEO4K0/nc9+jmNcxNAxRi6c5UWpqfw3KMFV4MVFWL+jQsSh+bGw2VqmaPMTLyw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.11.tgz",
+ "integrity": "sha512-qXBQQO9OvkjjQPLdUVr7Nr2t3QTZI7s4KZtfw7HzBgjbmAPSFwSv4rmET9lLSgq3rH/ndA3ngv3Qb8l2njoPNA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.11.tgz",
+ "integrity": "sha512-/tpFfoSTzUkH9LPY+cYbqZBDyyX62w5fICq9qzsHLL8uTI6BHip3Q9Uzft0wylk/i8OOwKik8OxW+QAhDmzwmg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.11.tgz",
+ "integrity": "sha512-mcp3Rio2w72IvdZG0oQ4bM2c2oumtwHfUfKncUM6zGgz0KgPz4YmDPQfnXEiY5t3+KD/i8HG2rOB/LxdmieK2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.11.tgz",
+ "integrity": "sha512-LXk5Hii1Ph9asuGRjBuz8TUxdc1lWzB7nyfdoRgI0WGPZKmCxvlKk8KfYysqtr4MfGElu/f/pEQRh8fcEgkrWw==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@napi-rs/wasm-runtime": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.11.tgz",
+ "integrity": "sha512-dDwf5otnx0XgRY1yqxOC4ITizcdzS/8cQ3goOWv3jFAo4F+xQYni+hnMuO6+LssHHdJW7+OCVL3CoU4ycnh35Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.11.tgz",
+ "integrity": "sha512-LN4/skhSggybX71ews7dAj6r2geaMJfm3kMbK2KhFMg9B10AZXnKoLCVVgzhMHL0S+aKtr4p8QbAW8k+w95bAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.7",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
+ "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
+ "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/node/node_modules/tailwindcss": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+ "license": "MIT"
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
+ "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.2",
+ "@tailwindcss/oxide-darwin-x64": "4.2.2",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.2",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz",
+ "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz",
+ "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz",
+ "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz",
+ "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz",
+ "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz",
+ "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz",
+ "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz",
+ "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz",
+ "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz",
+ "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
+ "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
+ "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
+ "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.2.2",
+ "@tailwindcss/oxide": "4.2.2",
+ "postcss": "^8.5.6",
+ "tailwindcss": "4.2.2"
+ }
+ },
+ "node_modules/@tailwindcss/postcss/node_modules/tailwindcss": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+ "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+ "license": "MIT"
+ },
+ "node_modules/@tanstack/react-table": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.21.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
+ "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/d3-array": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
+ "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-color": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
+ "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-ease": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
+ "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-interpolate": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
+ "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-color": "*"
+ }
+ },
+ "node_modules/@types/d3-path": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
+ "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-scale": {
+ "version": "4.0.9",
+ "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
+ "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-time": "*"
+ }
+ },
+ "node_modules/@types/d3-shape": {
+ "version": "3.1.8",
+ "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
+ "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/d3-path": "*"
+ }
+ },
+ "node_modules/@types/d3-time": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
+ "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
+ "license": "MIT"
+ },
+ "node_modules/@types/d3-timer": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
+ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.14",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+ "devOptional": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
+ "license": "MIT"
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
+ "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.7"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.16.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
+ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
+ "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/any-promise": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+ "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/anymatch": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+ "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "normalize-path": "^3.0.0",
+ "picomatch": "^2.0.4"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/anymatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/arg": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+ "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/autoprefixer": {
+ "version": "10.4.27",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
+ "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.1",
+ "caniuse-lite": "^1.0.30001774",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.10",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.10.tgz",
+ "integrity": "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/binary-extensions": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+ "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/braces": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+ "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fill-range": "^7.1.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.1",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
+ "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.9.0",
+ "caniuse-lite": "^1.0.30001759",
+ "electron-to-chromium": "^1.5.263",
+ "node-releases": "^2.0.27",
+ "update-browserslist-db": "^1.2.0"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/camelcase-css": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+ "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001781",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
+ "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/chokidar": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "anymatch": "~3.1.2",
+ "braces": "~3.0.2",
+ "glob-parent": "~5.1.2",
+ "is-binary-path": "~2.1.0",
+ "is-glob": "~4.0.1",
+ "normalize-path": "~3.0.0",
+ "readdirp": "~3.6.0"
+ },
+ "engines": {
+ "node": ">= 8.10.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/chokidar/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/commander": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+ "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/cssesc": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+ "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "cssesc": "bin/cssesc"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "devOptional": true,
+ "license": "MIT"
+ },
+ "node_modules/d3-array": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
+ "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
+ "license": "ISC",
+ "dependencies": {
+ "internmap": "1 - 2"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-color": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
+ "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-ease": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
+ "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-format": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
+ "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-interpolate": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
+ "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-color": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-path": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
+ "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-scale": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
+ "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2.10.0 - 3",
+ "d3-format": "1 - 3",
+ "d3-interpolate": "1.2.0 - 3",
+ "d3-time": "2.1.1 - 3",
+ "d3-time-format": "2 - 4"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-shape": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
+ "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-path": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
+ "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-array": "2 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-time-format": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
+ "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
+ "license": "ISC",
+ "dependencies": {
+ "d3-time": "1 - 3"
+ },
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/d3-timer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
+ "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js-light": {
+ "version": "2.5.1",
+ "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
+ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/didyoumean": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/dlv": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+ "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.322",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.322.tgz",
+ "integrity": "sha512-vFU34OcrvMcH66T+dYC3G4nURmgfDVewMIu6Q2urXpumAPSMmzvcn04KVVV8Opikq8Vs5nUbO/8laNhNRqSzYw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.20.1",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
+ "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.0"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/es-toolkit": {
+ "version": "1.45.1",
+ "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
+ "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
+ "license": "MIT",
+ "workspaces": [
+ "docs",
+ "benchmarks"
+ ]
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.4",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz",
+ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.5",
+ "@eslint/js": "9.39.4",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz",
+ "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
+ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
+ "license": "MIT"
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-glob": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@nodelib/fs.stat": "^2.0.2",
+ "@nodelib/fs.walk": "^1.2.3",
+ "glob-parent": "^5.1.2",
+ "merge2": "^1.3.0",
+ "micromatch": "^4.0.8"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fastq": {
+ "version": "1.20.1",
+ "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+ "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "reusify": "^1.0.4"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/fill-range": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+ "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "to-regex-range": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.2",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
+ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
+ "node_modules/framer-motion": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
+ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.38.0",
+ "motion-utils": "^12.36.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.4.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
+ "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "license": "ISC"
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
+ "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/immer": {
+ "version": "10.2.0",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
+ "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/internmap": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
+ "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/is-binary-path": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+ "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "binary-extensions": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-core-module": {
+ "version": "2.16.1",
+ "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+ "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hasown": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-number": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+ "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.12.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lilconfig": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+ "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/antonk52"
+ }
+ },
+ "node_modules/lines-and-columns": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lucide-react": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.6.0.tgz",
+ "integrity": "sha512-YxLKVCOF5ZDI1AhKQE5IBYMY9y/Nr4NT15+7QEWpsTSVCdn4vmZhww+6BP76jWYjQx8rSz1Z+gGme1f+UycWEw==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/micromatch": {
+ "version": "4.0.8",
+ "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+ "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "braces": "^3.0.3",
+ "picomatch": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=8.6"
+ }
+ },
+ "node_modules/micromatch/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/motion-dom": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
+ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.36.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.36.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
+ "license": "MIT"
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/mz": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+ "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0",
+ "object-assign": "^4.0.1",
+ "thenify-all": "^1.0.0"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.36",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz",
+ "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/normalize-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-hash": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+ "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-parse": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pify": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+ "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/pirates": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+ "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/postcss-import": {
+ "version": "15.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+ "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "postcss-value-parser": "^4.0.0",
+ "read-cache": "^1.0.0",
+ "resolve": "^1.1.7"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.0.0"
+ }
+ },
+ "node_modules/postcss-js": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+ "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "camelcase-css": "^2.0.1"
+ },
+ "engines": {
+ "node": "^12 || ^14 || >= 16"
+ },
+ "peerDependencies": {
+ "postcss": "^8.4.21"
+ }
+ },
+ "node_modules/postcss-load-config": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+ "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "lilconfig": "^3.1.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "peerDependencies": {
+ "jiti": ">=1.21.0",
+ "postcss": ">=8.0.9",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ },
+ "postcss": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss-nested": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+ "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "postcss-selector-parser": "^6.1.1"
+ },
+ "engines": {
+ "node": ">=12.0"
+ },
+ "peerDependencies": {
+ "postcss": "^8.2.14"
+ }
+ },
+ "node_modules/postcss-selector-parser": {
+ "version": "6.1.2",
+ "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+ "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "cssesc": "^3.0.0",
+ "util-deprecate": "^1.0.2"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/queue-microtask": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+ "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
+ "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
+ "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.4"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "19.2.4",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
+ "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/read-cache": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+ "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "pify": "^2.3.0"
+ }
+ },
+ "node_modules/readdirp": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+ "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "picomatch": "^2.2.1"
+ },
+ "engines": {
+ "node": ">=8.10.0"
+ }
+ },
+ "node_modules/readdirp/node_modules/picomatch": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+ "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/recharts": {
+ "version": "3.8.1",
+ "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
+ "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
+ "license": "MIT",
+ "workspaces": [
+ "www"
+ ],
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.9.0 || 2.x.x",
+ "clsx": "^2.1.1",
+ "decimal.js-light": "^2.5.1",
+ "es-toolkit": "^1.39.3",
+ "eventemitter3": "^5.0.1",
+ "immer": "^10.1.1",
+ "react-redux": "8.x.x || 9.x.x",
+ "reselect": "5.1.1",
+ "tiny-invariant": "^1.3.3",
+ "use-sync-external-store": "^1.2.2",
+ "victory-vendor": "^37.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
+ "license": "MIT"
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+ "license": "MIT"
+ },
+ "node_modules/resolve": {
+ "version": "1.22.11",
+ "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
+ "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-core-module": "^2.16.1",
+ "path-parse": "^1.0.7",
+ "supports-preserve-symlinks-flag": "^1.0.0"
+ },
+ "bin": {
+ "resolve": "bin/resolve"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/reusify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+ "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "iojs": ">=1.0.0",
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.11.tgz",
+ "integrity": "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.122.0",
+ "@rolldown/pluginutils": "1.0.0-rc.11"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.0.0-rc.11",
+ "@rolldown/binding-darwin-arm64": "1.0.0-rc.11",
+ "@rolldown/binding-darwin-x64": "1.0.0-rc.11",
+ "@rolldown/binding-freebsd-x64": "1.0.0-rc.11",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11",
+ "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11",
+ "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11",
+ "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11",
+ "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11",
+ "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11",
+ "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11",
+ "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11",
+ "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11",
+ "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11",
+ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11"
+ }
+ },
+ "node_modules/rolldown/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.11",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.11.tgz",
+ "integrity": "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/run-parallel": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+ "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "queue-microtask": "^1.2.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/sucrase": {
+ "version": "3.35.1",
+ "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+ "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.2",
+ "commander": "^4.0.0",
+ "lines-and-columns": "^1.1.6",
+ "mz": "^2.7.0",
+ "pirates": "^4.0.1",
+ "tinyglobby": "^0.2.11",
+ "ts-interface-checker": "^0.1.9"
+ },
+ "bin": {
+ "sucrase": "bin/sucrase",
+ "sucrase-node": "bin/sucrase-node"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/supports-preserve-symlinks-flag": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+ "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
+ "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "3.4.19",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+ "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "arg": "^5.0.2",
+ "chokidar": "^3.6.0",
+ "didyoumean": "^1.2.2",
+ "dlv": "^1.1.3",
+ "fast-glob": "^3.3.2",
+ "glob-parent": "^6.0.2",
+ "is-glob": "^4.0.3",
+ "jiti": "^1.21.7",
+ "lilconfig": "^3.1.3",
+ "micromatch": "^4.0.8",
+ "normalize-path": "^3.0.0",
+ "object-hash": "^3.0.0",
+ "picocolors": "^1.1.1",
+ "postcss": "^8.4.47",
+ "postcss-import": "^15.1.0",
+ "postcss-js": "^4.0.1",
+ "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+ "postcss-nested": "^6.2.0",
+ "postcss-selector-parser": "^6.1.2",
+ "resolve": "^1.22.8",
+ "sucrase": "^3.35.0"
+ },
+ "bin": {
+ "tailwind": "lib/cli.js",
+ "tailwindcss": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tailwindcss/node_modules/jiti": {
+ "version": "1.21.7",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "bin/jiti.js"
+ }
+ },
+ "node_modules/tapable": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
+ "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/thenify": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+ "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "any-promise": "^1.0.0"
+ }
+ },
+ "node_modules/thenify-all": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+ "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "thenify": ">= 3.1.0 < 4"
+ },
+ "engines": {
+ "node": ">=0.8"
+ }
+ },
+ "node_modules/tiny-invariant": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
+ "license": "MIT"
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/to-regex-range": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+ "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-number": "^7.0.0"
+ },
+ "engines": {
+ "node": ">=8.0"
+ }
+ },
+ "node_modules/ts-interface-checker": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+ "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/victory-vendor": {
+ "version": "37.3.6",
+ "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
+ "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
+ "license": "MIT AND ISC",
+ "dependencies": {
+ "@types/d3-array": "^3.0.3",
+ "@types/d3-ease": "^3.0.0",
+ "@types/d3-interpolate": "^3.0.1",
+ "@types/d3-scale": "^4.0.2",
+ "@types/d3-shape": "^3.1.0",
+ "@types/d3-time": "^3.0.0",
+ "@types/d3-timer": "^3.0.0",
+ "d3-array": "^3.1.6",
+ "d3-ease": "^3.0.1",
+ "d3-interpolate": "^3.0.1",
+ "d3-scale": "^4.0.2",
+ "d3-shape": "^3.1.0",
+ "d3-time": "^3.0.0",
+ "d3-timer": "^3.0.1"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.0.2",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.2.tgz",
+ "integrity": "sha512-1gFhNi+bHhRE/qKZOJXACm6tX4bA3Isy9KuKF15AgSRuRazNBOJfdDemPBU16/mpMxApDPrWvZ08DcLPEoRnuA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.8",
+ "rolldown": "1.0.0-rc.11",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.1.0",
+ "esbuild": "^0.27.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..8ee5775daf64c6fbe69e1ae518061334ca407c23
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "frontend",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@dnd-kit/core": "^6.3.1",
+ "@dnd-kit/sortable": "^10.0.0",
+ "@dnd-kit/utilities": "^3.2.2",
+ "@react-oauth/google": "^0.13.4",
+ "@tailwindcss/postcss": "^4.2.2",
+ "@tanstack/react-table": "^8.21.3",
+ "clsx": "^2.1.1",
+ "framer-motion": "^12.38.0",
+ "lucide-react": "^1.6.0",
+ "react": "^19.2.4",
+ "react-dom": "^19.2.4",
+ "recharts": "^3.8.1",
+ "tailwind-merge": "^3.5.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.4",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "autoprefixer": "^10.4.27",
+ "eslint": "^9.39.4",
+ "eslint-plugin-react-hooks": "^7.0.1",
+ "eslint-plugin-react-refresh": "^0.5.2",
+ "globals": "^17.4.0",
+ "postcss": "^8.5.8",
+ "tailwindcss": "^3.4.19",
+ "vite": "^8.0.1"
+ }
+}
diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d
--- /dev/null
+++ b/frontend/postcss.config.js
@@ -0,0 +1,6 @@
+export default {
+ plugins: {
+ tailwindcss: {},
+ autoprefixer: {},
+ },
+}
diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6893eb13237060adc0c968a690149a49faa2d7d3
--- /dev/null
+++ b/frontend/public/favicon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/public/hero.png b/frontend/public/hero.png
new file mode 100644
index 0000000000000000000000000000000000000000..cc51a3d20ad4bc961b596a6adfd686685cd84bb0
Binary files /dev/null and b/frontend/public/hero.png differ
diff --git a/frontend/public/icon.jpg b/frontend/public/icon.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..c52ffcf29c5f4ee0ccc70078fd89d28f4dd666f6
Binary files /dev/null and b/frontend/public/icon.jpg differ
diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg
new file mode 100644
index 0000000000000000000000000000000000000000..e9522193d9f796a9748e9ad8c952a5df73c87db9
--- /dev/null
+++ b/frontend/public/icons.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/public/image.png b/frontend/public/image.png
new file mode 100644
index 0000000000000000000000000000000000000000..26de53b4a94e7f07bff1fd3a9ae4cbba8d87fb4d
Binary files /dev/null and b/frontend/public/image.png differ
diff --git a/frontend/public/l-logo.png b/frontend/public/l-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..26de53b4a94e7f07bff1fd3a9ae4cbba8d87fb4d
Binary files /dev/null and b/frontend/public/l-logo.png differ
diff --git a/frontend/public/luigismansion.jpg b/frontend/public/luigismansion.jpg
new file mode 100644
index 0000000000000000000000000000000000000000..297402c2dfd8b374980cc8fb249d85196fb83835
Binary files /dev/null and b/frontend/public/luigismansion.jpg differ
diff --git a/frontend/src/App.css b/frontend/src/App.css
new file mode 100644
index 0000000000000000000000000000000000000000..f90339d8f765fa2c69d9a341959a8ddb9fff5720
--- /dev/null
+++ b/frontend/src/App.css
@@ -0,0 +1,184 @@
+.counter {
+ font-size: 16px;
+ padding: 5px 10px;
+ border-radius: 5px;
+ color: var(--accent);
+ background: var(--accent-bg);
+ border: 2px solid transparent;
+ transition: border-color 0.3s;
+ margin-bottom: 24px;
+
+ &:hover {
+ border-color: var(--accent-border);
+ }
+ &:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+ }
+}
+
+.hero {
+ position: relative;
+
+ .base,
+ .framework,
+ .vite {
+ inset-inline: 0;
+ margin: 0 auto;
+ }
+
+ .base {
+ width: 170px;
+ position: relative;
+ z-index: 0;
+ }
+
+ .framework,
+ .vite {
+ position: absolute;
+ }
+
+ .framework {
+ z-index: 1;
+ top: 34px;
+ height: 28px;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
+ scale(1.4);
+ }
+
+ .vite {
+ z-index: 0;
+ top: 107px;
+ height: 26px;
+ width: auto;
+ transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
+ scale(0.8);
+ }
+}
+
+#center {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ place-content: center;
+ place-items: center;
+ flex-grow: 1;
+
+ @media (max-width: 1024px) {
+ padding: 32px 20px 24px;
+ gap: 18px;
+ }
+}
+
+#next-steps {
+ display: flex;
+ border-top: 1px solid var(--border);
+ text-align: left;
+
+ & > div {
+ flex: 1 1 0;
+ padding: 32px;
+ @media (max-width: 1024px) {
+ padding: 24px 20px;
+ }
+ }
+
+ .icon {
+ margin-bottom: 16px;
+ width: 22px;
+ height: 22px;
+ }
+
+ @media (max-width: 1024px) {
+ flex-direction: column;
+ text-align: center;
+ }
+}
+
+#docs {
+ border-right: 1px solid var(--border);
+
+ @media (max-width: 1024px) {
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ }
+}
+
+#next-steps ul {
+ list-style: none;
+ padding: 0;
+ display: flex;
+ gap: 8px;
+ margin: 32px 0 0;
+
+ .logo {
+ height: 18px;
+ }
+
+ a {
+ color: var(--text-h);
+ font-size: 16px;
+ border-radius: 6px;
+ background: var(--social-bg);
+ display: flex;
+ padding: 6px 12px;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+ transition: box-shadow 0.3s;
+
+ &:hover {
+ box-shadow: var(--shadow);
+ }
+ .button-icon {
+ height: 18px;
+ width: 18px;
+ }
+ }
+
+ @media (max-width: 1024px) {
+ margin-top: 20px;
+ flex-wrap: wrap;
+ justify-content: center;
+
+ li {
+ flex: 1 1 calc(50% - 8px);
+ }
+
+ a {
+ width: 100%;
+ justify-content: center;
+ box-sizing: border-box;
+ }
+ }
+}
+
+#spacer {
+ height: 88px;
+ border-top: 1px solid var(--border);
+ @media (max-width: 1024px) {
+ height: 48px;
+ }
+}
+
+.ticks {
+ position: relative;
+ width: 100%;
+
+ &::before,
+ &::after {
+ content: '';
+ position: absolute;
+ top: -4.5px;
+ border: 5px solid transparent;
+ }
+
+ &::before {
+ left: 0;
+ border-left-color: var(--border);
+ }
+ &::after {
+ right: 0;
+ border-right-color: var(--border);
+ }
+}
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..17492dd24dddb26c22c0c5c9a99b518fffddb2f6
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,280 @@
+import React, { useState, useContext, useEffect } from "react";
+import { motion, AnimatePresence } from "framer-motion";
+import {
+ Activity,
+ BarChart2,
+ Shield,
+ Calendar,
+ Zap,
+ LogIn,
+ Settings,
+ X,
+} from "lucide-react";
+
+import { LandingPage } from "./components/LandingPage";
+import ProjectionsTable from "./components/ProjectionsTable";
+import AccuracyDashboard from "./components/AccuracyDashboard";
+import TeamRatings from "./components/TeamRatings";
+import Fixtures from "./components/Fixtures";
+import Solver from "./components/Solver";
+import LoginModal from "./components/LoginModal";
+import { PlayerProvider, PlayerContext } from "./PlayerContext";
+
+
+const tabs = [
+ { id: "solver", label: "Solver", icon: Zap },
+ { id: "projections", label: "Projections", icon: Activity },
+ { id: "accuracy", label: "Accuracy", icon: BarChart2 },
+ { id: "ratings", label: "Team Ratings", icon: Shield },
+ { id: "fixtures", label: "Fixtures", icon: Calendar },
+];
+
+function AppContent() {
+ const [activeTab, setActiveTab] = useState(tabs[0].id);
+ const [showLoginModal, setShowLoginModal] = useState(false);
+ const [showSettings, setShowSettings] = useState(false);
+
+ const {
+ isLoggedIn,
+ setIsLoggedIn,
+ userProfile,
+ setUserProfile,
+ hasGuestMadeEdits,
+ setHasGuestMadeEdits,
+ isCheckingAuth, // <-- Pull in the new state
+ } = useContext(PlayerContext);
+
+ const [newDefaultId, setNewDefaultId] = useState("");
+ const [isSaved, setIsSaved] = useState(false);
+
+ // Sync local input with context profile
+ useEffect(() => {
+ if (userProfile?.defaultTeamId) {
+ setNewDefaultId(userProfile.defaultTeamId);
+ }
+ }, [userProfile]);
+
+ const handleUpdateDefaultId = () => {
+ const parsedId = parseInt(newDefaultId);
+ if (!parsedId) return;
+
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ default_team_id: parsedId })
+ }).then(() => {
+ setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId }));
+ setIsSaved(true);
+ setTimeout(() => setIsSaved(false), 2000);
+ });
+ }
+ };
+
+ // --- THE NEW LOADING INTERCEPTOR ---
+ // If we are actively checking the token, show a sleek loading screen instead of the landing page
+ if (isCheckingAuth) {
+ return (
+
+
+
Entering Mansion...
+
+ );
+ }
+
+ // If we finished checking and they definitely aren't logged in, show the gatekeeper
+ if (!isLoggedIn) {
+ return ;
+ }
+
+ const handleLogout = () => {
+ localStorage.removeItem("fpl_token");
+ setIsLoggedIn(false);
+ setUserProfile({ username: "Guest", defaultTeamId: null, isAdmin: false });
+ setShowSettings(false);
+ setActiveTab("solver"); // <-- Forces the app back to the Team ID loading page!
+ };
+
+ return (
+
+
+
+
+ {/* Solver is always mounted so local state (snapshot, pairs, highlights) survives tab switches */}
+
+
+
+
+ {/* Every other tab is animated and only rendered when active */}
+ {activeTab !== "solver" && (
+
+
+ {activeTab === "projections" && }
+ {activeTab === "accuracy" && }
+ {activeTab === "ratings" && }
+ {activeTab === "fixtures" && }
+
+
+ )}
+
+
+
setShowLoginModal(false)}
+ />
+
+ );
+}
+
+export default function App() {
+ return (
+
+
+
+ );
+}
diff --git a/frontend/src/PlayerContext.jsx b/frontend/src/PlayerContext.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..81d19be68d68a7ec6eea2338173c9a4bb176c892
--- /dev/null
+++ b/frontend/src/PlayerContext.jsx
@@ -0,0 +1,572 @@
+// src/PlayerContext.jsx
+import React, { createContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
+
+export const PlayerContext = createContext();
+
+export const PlayerProvider = ({ children }) => {
+ const globalFixturesRef = useRef({});
+ const [originalPlayers, setOriginalPlayers] = useState([]);
+ const [globalPlayers, setGlobalPlayers] = useState([]);
+ const [globalFixtures, setGlobalFixtures] = useState({});
+ const [isLoadingDB, setIsLoadingDB] = useState(true);
+
+ const [teamId, setTeamId] = useState('');
+ const [availableGWs, setAvailableGWs] = useState([]);
+ const [itb, setItb] = useState(0);
+ const [availableFts, setAvailableFts] = useState(1);
+ const [initialSquadIds, setInitialSquadIds] = useState([]);
+ const [solverResult, setSolverResult] = useState(null);
+ const [activeChip, setActiveChip] = useState(null);
+ const [solveElapsedSec, setSolveElapsedSec] = useState(0);
+ const [numSims, setNumSims] = useState(100);
+ const HIT_COST = 4;
+
+ const [baselineItb, setBaselineItb] = useState(0);
+ const [baselineFt, setBaselineFt] = useState(1);
+ const [comprehensiveSettings, setComprehensiveSettings] = useState({});
+ const [globalXmins, setGlobalXmins] = useState({});
+
+
+ const [quickSettings, setQuickSettings] = useState({
+ decay: 0.85,
+ ft_value: 1.5,
+ iterations: 1,
+ banned: [],
+ locked: [],
+ });
+
+ const [advancedSettings, setAdvancedSettings] = useState({
+ hit_cost: 4,
+ itb_value: 0.08,
+ max_per_team: 3,
+ vice_weight: 0.05,
+ time_limit_sec: 30,
+ no_transfer_last_gws: 0
+ });
+
+ // FIXED: FPL 24/25 FT Rollover Logic
+ const ftAtStartOfGw = (targetGw, gwsArray, baseFt, trByGw, chByGw) => {
+ if (!gwsArray || !gwsArray.length) return baseFt;
+ let ft = baseFt;
+ for (let gw = gwsArray[0]; gw < targetGw; gw++) {
+ const chip = chByGw[gw];
+ if (chip === 'wc' || chip === 'fh') {
+ ft = Math.min(5, ft);
+ } else {
+ const used = trByGw[gw]?.count || 0;
+ ft = Math.min(5, Math.max(0, ft - used) + 1);
+ }
+ }
+ return Math.max(1, Math.min(5, ft));
+ };
+
+ const itbAtStartOfGw = (targetGw, gwsArray, baseItb, trByGw) => {
+ let currentItb = baseItb;
+ if (!gwsArray || !gwsArray.length) return currentItb;
+ for (let gw = gwsArray[0]; gw < targetGw; gw++) {
+ currentItb += (trByGw[gw]?.netDelta || 0);
+ }
+ return currentItb;
+ };
+
+ // =========================================================
+ // --- MULTIVERSE DRAFTS ENGINE (Phase 1) ---
+ // =========================================================
+ const [drafts, setDrafts] = useState([{
+ id: "main_1",
+ name: "Main Timeline",
+ teamData: [],
+ horizon: 5,
+ activeGW: null,
+ captainId: null,
+ viceId: null,
+ solverTransferPairs: {},
+ solverApplySnapshot: null,
+ appliedPlanSummary: null,
+ hitsThisGw: 0,
+ highlightTransferIds: {},
+ transfersByGw: {},
+ chipsByGw: {},
+ manualOverrides: {},
+ fixtureOverrides: {}, // <-- ADDED: Isolated Fixtures
+ sessionEdits: {} // <-- ADDED: Isolated Minutes
+ }]);
+
+ const [activeDraftId, setActiveDraftId] = useState("main_1");
+
+ // 1. EXTRACT CURRENT REALITY
+ const activeDraft = drafts.find(d => d.id === activeDraftId) || drafts[0];
+
+ const teamData = activeDraft.teamData;
+ const horizon = activeDraft.horizon;
+ const activeGW = activeDraft.activeGW;
+ const captainId = activeDraft.captainId;
+ const viceId = activeDraft.viceId;
+ const solverTransferPairs = activeDraft.solverTransferPairs || {};
+ const solverApplySnapshot = activeDraft.solverApplySnapshot;
+ const appliedPlanSummary = activeDraft.appliedPlanSummary;
+ const hitsThisGw = activeDraft.hitsThisGw;
+ const highlightTransferIds = activeDraft.highlightTransferIds || {};
+ const transfersByGw = activeDraft.transfersByGw || {};
+ const chipsByGw = activeDraft.chipsByGw || {};
+
+ // Safe extraction fallbacks for older local cache hits
+ const manualOverrides = activeDraft.manualOverrides || {};
+ const fixtureOverrides = activeDraft.fixtureOverrides || {};
+ const sessionEdits = activeDraft.sessionEdits || {};
+
+ const effectiveFixtures = useMemo(() => {
+ return { ...globalFixtures, ...(fixtureOverrides || {}) };
+ }, [globalFixtures, fixtureOverrides]);
+
+ // 2. PROXY SETTERS (Intercepts state calls and routes them to the active draft)
+ const updateDraftState = useCallback((key, newValue) => {
+ setDrafts(prevDrafts => {
+ const activeIndex = prevDrafts.findIndex(d => d.id === activeDraftId);
+ if (activeIndex === -1) return prevDrafts;
+
+ const draft = prevDrafts[activeIndex];
+ // Provide an empty object fallback for expected object states to prevent functional crashes
+ const currentValue = draft[key] !== undefined ? draft[key] : (key === 'teamData' || key === 'availableGWs' ? [] : {});
+ const evaluatedValue = typeof newValue === 'function' ? newValue(currentValue) : newValue;
+
+ const newDrafts = [...prevDrafts];
+ newDrafts[activeIndex] = { ...draft, [key]: evaluatedValue };
+ return newDrafts;
+ });
+ }, [activeDraftId]);
+
+ const setTeamData = useCallback((val) => updateDraftState("teamData", val), [updateDraftState]);
+ const setHorizon = useCallback((val) => updateDraftState("horizon", val), [updateDraftState]);
+ const setActiveGW = useCallback((val) => updateDraftState("activeGW", val), [updateDraftState]);
+ const setCaptainId = useCallback((val) => updateDraftState("captainId", val), [updateDraftState]);
+ const setViceId = useCallback((val) => updateDraftState("viceId", val), [updateDraftState]);
+ const setSolverTransferPairs = useCallback((val) => updateDraftState("solverTransferPairs", val), [updateDraftState]);
+ const setSolverApplySnapshot = useCallback((val) => updateDraftState("solverApplySnapshot", val), [updateDraftState]);
+ const setAppliedPlanSummary = useCallback((val) => updateDraftState("appliedPlanSummary", val), [updateDraftState]);
+ const setHitsThisGw = useCallback((val) => updateDraftState("hitsThisGw", val), [updateDraftState]);
+ const setHighlightTransferIds = useCallback((val) => updateDraftState("highlightTransferIds", val), [updateDraftState]);
+ const setTransfersByGw = useCallback((val) => updateDraftState("transfersByGw", val), [updateDraftState]);
+ const setChipsByGw = useCallback((val) => updateDraftState("chipsByGw", val), [updateDraftState]);
+ const setFixtureOverrides = useCallback((val) => updateDraftState("fixtureOverrides", val), [updateDraftState]); // <-- GHOST PATCH
+ const setSessionEdits = useCallback((val) => updateDraftState("sessionEdits", val), [updateDraftState]); // <-- GHOST PATCH
+ // =========================================================
+
+ const manualOverridesRef = useRef(manualOverrides);
+ useEffect(() => { manualOverridesRef.current = manualOverrides; }, [manualOverrides]);
+
+ const [projSearchTerm, setProjSearchTerm] = useState('');
+ const sessionEditsRef = useRef(sessionEdits);
+ useEffect(() => { sessionEditsRef.current = sessionEdits; }, [sessionEdits]);
+
+ const [isLoggedIn, setIsLoggedIn] = useState(false);
+ const [isCheckingAuth, setIsCheckingAuth] = useState(true);
+ const [userProfile, setUserProfile] = useState({ username: "Guest", defaultTeamId: null, isAdmin: false });
+ const [hasGuestMadeEdits, setHasGuestMadeEdits] = useState(false);
+ const [pendingWorkspaceLoad, setPendingWorkspaceLoad] = useState(null);
+
+ // Custom proxy setter for manualOverrides to retain your exact Auth saving logic
+ const setManualOverrides = useCallback((updater) => {
+ updateDraftState("manualOverrides", (prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : updater;
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ saved_edits: { ...sessionEditsRef.current, _solver_overrides: next } })
+ });
+ }
+ return next;
+ });
+ }, [updateDraftState]);
+
+ const saveSession = (overrides) => {
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({
+ saved_edits: { ...sessionEditsRef.current, _solver_overrides: overrides },
+ drafts: drafts
+ })
+ });
+ }
+ };
+
+ useEffect(() => {
+ // YOUR EXACT PROJECTIONS API ENDPOINT RESTORED
+ fetch('https://anayshukla-fpl-solver.hf.space/api/projections')
+ .then(res => { if (!res.ok) throw new Error("DB Error"); return res.json(); })
+ .then(data => {
+ setOriginalPlayers(JSON.parse(JSON.stringify(data)));
+ setGlobalPlayers(data);
+ setIsLoadingDB(false);
+ })
+ .catch(err => setIsLoadingDB(false));
+
+ // THE FIX: Store global fixtures in the Ref so the Auth wipe doesn't delete them!
+ fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/overrides')
+ .then(res => res.ok ? res.json() : {})
+ .then(data => {
+ if (Object.keys(data).length > 0) setGlobalFixtures(data);
+ })
+ .catch(err => console.error("Failed to load global fixtures:", err));
+
+ fetch('https://anayshukla-fpl-solver.hf.space/api/xmins/overrides')
+ .then(res => res.ok ? res.json() : {})
+ .then(data => { if (Object.keys(data).length > 0) setGlobalXmins(data); })
+ .catch(err => console.error("Failed to load global xMins:", err));
+ }, []);
+
+ useEffect(() => {
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/me', { headers: { 'Authorization': `Bearer ${token}` } })
+ .then(res => res.json())
+ .then(data => {
+ if (data.email) {
+ setUserProfile({ username: data.email.split('@')[0], defaultTeamId: data.default_team_id, isAdmin: data.is_admin });
+
+ // THE FIX: Inject the Multiverse realities from the database!
+ if (data.drafts && data.drafts.length > 0) {
+ setDrafts(data.drafts);
+ const saved = data.saved_edits || {};
+ if (saved._active_draft_id && data.drafts.some(d => d.id === saved._active_draft_id)) {
+ setActiveDraftId(saved._active_draft_id);
+ } else {
+ setActiveDraftId(data.drafts[0].id);
+ }
+ }
+
+ const saved = data.saved_edits || {};
+ if (saved._solver_overrides) {
+ setManualOverrides(saved._solver_overrides);
+ delete saved._solver_overrides;
+ }
+ if (saved._workspace) {
+ setPendingWorkspaceLoad(saved._workspace);
+ delete saved._workspace;
+ }
+ setSessionEdits(saved);
+ setIsLoggedIn(true);
+ if (data.default_team_id) setTeamId(String(data.default_team_id));
+ } else {
+ localStorage.removeItem('fpl_token');
+ setSessionEdits({});
+ setManualOverrides({});
+ setTeamId('');
+ setTeamData([]);
+ }
+ }).catch(() => {
+ localStorage.removeItem('fpl_token');
+ setSessionEdits({});
+ setManualOverrides({});
+ setTeamId('');
+ setTeamData([]);
+ }).finally(() => {
+ setIsCheckingAuth(false);
+ });
+ } else {
+ setSessionEdits({});
+ setManualOverrides({});
+ setTeamId('');
+ setTeamData([]);
+ setIsCheckingAuth(false);
+ }
+ }, [isLoggedIn]);
+
+ useEffect(() => {
+ if (originalPlayers.length > 0) {
+ setGlobalPlayers(prev => {
+ const newPlayers = JSON.parse(JSON.stringify(originalPlayers));
+
+ // --- 1. THE STOCHASTIC FIXTURE ENGINE ---
+ newPlayers.forEach(p => {
+ if (p.match_projections) {
+ // A. Zero out old stats + Track probability sum for averaging
+ const gwKeys = Object.keys(p).filter(k => k.includes('_Pts')).map(k => k.split('_')[0]);
+ gwKeys.forEach(gw => {
+ p[`${gw}_Pts`] = 0; p[`${gw}_xMins`] = 0; p[`${gw}_xG`] = 0; p[`${gw}_xA`] = 0; p[`${gw}_CS`] = 0;
+ p[`${gw}_probSum`] = 0; // NEW: Tracks total matches in this GW
+ });
+
+ // B. Loop matches and apply Match-Level Minute Edits
+ const manualBaseline = sessionEdits[p.ID]?.baseline_xMins;
+ const origBaseline = p.baseline_xMins || 90;
+ const baselineScale = (manualBaseline != null && origBaseline > 0) ? (Number(manualBaseline) / origBaseline) : 1.0;
+
+ Object.entries(p.match_projections).forEach(([matchId, mData]) => {
+ const override = effectiveFixtures[matchId];;
+
+ let manualMins = sessionEdits[p.ID]?.[`${matchId}_xMins`];
+ let globalMatchMins = globalXmins[p.ID]?.[matchId];
+
+ if (manualMins === undefined) {
+ if (globalMatchMins !== undefined) {
+ manualMins = globalMatchMins;
+ } else {
+ let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw;
+ if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw];
+ }
+ }
+
+ // THE FIX: Apply match edit, OR dynamically scale by the baseline edit!
+ const activeMins = manualMins != null
+ ? Number(manualMins)
+ : Math.min((mData.xMins * baselineScale), 90);
+
+ // Scale the match EV based on the active minutes
+ const scaling = (activeMins > 0 && mData.xMins > 0) ? (activeMins / mData.xMins) : 0;
+ const aPts = mData.Pts * scaling;
+ const axG = mData.xG * scaling;
+ const axA = mData.xA * scaling;
+ const aCS = mData.CS * scaling;
+
+ // C. Distribute the scaled EV
+ if (override) {
+ Object.entries(override).forEach(([gwStr, prob]) => {
+ const gw = gwStr;
+ p[`${gw}_Pts`] += (aPts * prob);
+ p[`${gw}_xMins`] += (activeMins * prob);
+ p[`${gw}_probSum`] += prob; // Add probability to the GW sum
+ p[`${gw}_xG`] += (axG * prob); p[`${gw}_xA`] += (axA * prob); p[`${gw}_CS`] += (aCS * prob);
+ });
+ } else {
+ const gw = mData.default_gw;
+ p[`${gw}_Pts`] += aPts;
+ p[`${gw}_xMins`] += activeMins;
+ p[`${gw}_probSum`] += 1.0;
+ p[`${gw}_xG`] += axG; p[`${gw}_xA`] += axA; p[`${gw}_CS`] += aCS;
+ }
+ });
+
+ // D. Calculate FPL Average xMins
+ gwKeys.forEach(gw => {
+ if (p[`${gw}_probSum`] > 0) {
+ p[`${gw}_xMins`] = Math.round(p[`${gw}_xMins`] / p[`${gw}_probSum`]);
+ }
+ });
+ }
+ });
+
+ // --- 2. APPLY MANUAL SESSION EDITS (Overwrites everything) ---
+ if (Object.keys(sessionEdits).length > 0) {
+ Object.keys(sessionEdits).forEach(playerId => {
+ if (playerId === '_solver_overrides') return;
+
+ const pid = parseInt(playerId);
+ const pIdx = newPlayers.findIndex(p => p.ID === pid);
+ if (pIdx > -1) {
+ const edits = sessionEdits[playerId];
+ Object.keys(edits).forEach(editKey => {
+ newPlayers[pIdx][editKey] = edits[editKey];
+ if (editKey.includes('_xMins') && !editKey.includes('_vs_')) {
+ const gw = editKey.split('_')[0];
+ if (edits[`${gw}_Pts`] === undefined) {
+ const baseMins = originalPlayers[pIdx][`${gw}_xMins`] || 90;
+ const basePts = originalPlayers[pIdx][`${gw}_Pts`] || 0;
+ newPlayers[pIdx][`${gw}_Pts`] = baseMins > 0 ? (basePts / baseMins) * edits[editKey] : 0;
+ }
+ }
+ });
+ }
+ });
+ }
+
+ return newPlayers;
+ });
+ }
+ }, [originalPlayers, isLoggedIn, sessionEdits, effectiveFixtures]);
+
+ useEffect(() => {
+ if (!isLoggedIn || isLoadingDB || globalPlayers.length === 0) return;
+
+ const timeout = setTimeout(() => {
+ const workspace = {
+ teamData: teamData.map(p => ({ ID: p.ID, Price: p.Price, isBlank: p.isBlank, replacedPlayer: p.replacedPlayer })),
+ horizon,
+ activeGW,
+ baselineItb,
+ baselineFt,
+ transfersByGw,
+ chipsByGw,
+ quickSettings,
+ advancedSettings,
+ highlightTransferIds: Object.fromEntries(Object.entries(highlightTransferIds).map(([k,v]) => [k, Array.from(v)])),
+ solverTransferPairs
+ };
+
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({
+ saved_edits: {
+ ...sessionEditsRef.current,
+ _solver_overrides: manualOverridesRef.current,
+ _workspace: workspace,
+ _active_draft_id: activeDraftId
+ },
+ drafts: drafts // <-- THE FIX: Package and send the entire Multiverse!
+ })
+ });
+ }
+ }, 500);
+ // THE FIX: Added 'drafts' to the end of this dependency array so edits trigger the save
+ }, [teamData, horizon, activeGW, baselineItb, baselineFt, transfersByGw, chipsByGw, quickSettings, advancedSettings, highlightTransferIds, solverTransferPairs, isLoggedIn, isLoadingDB, globalPlayers, drafts]);
+
+ useEffect(() => {
+ if (globalPlayers.length === 0 || teamData.length === 0) return;
+
+ setTeamData(prevTeam => {
+ let needsUpdate = false;
+
+ const syncedTeam = prevTeam.map(tp => {
+ const gp = globalPlayers.find(p => p.ID === tp.ID);
+ if (!gp) return tp;
+
+ // If the squad's math is out of sync with the global math, flag it for an update!
+ // (We check a few sample gameweeks to guarantee we catch the mismatch)
+ if (
+ tp['1_Pts'] !== gp['1_Pts'] ||
+ tp['19_Pts'] !== gp['19_Pts'] ||
+ tp['38_Pts'] !== gp['38_Pts'] ||
+ tp.baseline_xMins !== gp.baseline_xMins
+ ) {
+ needsUpdate = true;
+ return { ...tp, ...gp, Price: tp.Price }; // Merges the math, but protects your specific Selling Price!
+ }
+ return tp;
+ });
+
+ // Only triggers a re-render if it actually found stale data
+ return needsUpdate ? syncedTeam : prevTeam;
+ });
+ }, [globalPlayers, teamData, setTeamData]);
+
+ const updatePlayerStat = (playerId, gw, statKey, rawValue) => {
+ let finalValue = statKey === 'xMins' ? Math.min(Math.max(Number(rawValue), 0), 90) : rawValue;
+ if (!isLoggedIn) setHasGuestMadeEdits(true);
+
+ const pristinePlayer = originalPlayers.find(p => p.ID === playerId);
+ let calculatedPts = 0;
+
+ // 1. Determine the exact original value to see if we are reverting
+ let isRevertingToOriginal = false;
+ const isMatchId = String(gw).includes('_vs_');
+
+ // FIX: Look specifically inside match_projections for DGW match edits!
+ if (pristinePlayer && pristinePlayer.match_projections && isMatchId) {
+ const mData = pristinePlayer.match_projections[gw];
+ if (mData) {
+ const mOrig = statKey === 'xMins' ? mData.xMins : mData[statKey];
+ isRevertingToOriginal = (finalValue === mOrig);
+ }
+ } else {
+ const originalValue = pristinePlayer ? pristinePlayer[`${gw}_${statKey}`] : undefined;
+ if (originalValue !== undefined) {
+ isRevertingToOriginal = (finalValue === originalValue);
+ } else if (statKey === 'xMins' && finalValue === 90) {
+ isRevertingToOriginal = true;
+ }
+ }
+
+ // Calculate instant EV for UI feedback
+ if (statKey === 'xMins') {
+ if (pristinePlayer && pristinePlayer.match_projections && isMatchId) {
+ const mData = pristinePlayer.match_projections[gw];
+ const baseMins = mData ? mData.xMins : 90;
+ const basePts = mData ? mData.Pts : 0;
+ calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0;
+ } else {
+ const baseMins = pristinePlayer ? pristinePlayer[`${gw}_xMins`] || 90 : 90;
+ const basePts = pristinePlayer ? pristinePlayer[`${gw}_Pts`] || 0 : 0;
+ calculatedPts = baseMins > 0 ? (basePts / baseMins) * finalValue : 0;
+ }
+ }
+
+ setGlobalPlayers(prev => prev.map(p => {
+ if (p.ID === playerId) {
+ let updated = { ...p, [`${gw}_${statKey}`]: finalValue };
+ if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts;
+ return updated;
+ }
+ return p;
+ }));
+
+ setTeamData(prev => prev.map(p => {
+ if (p.ID === playerId) {
+ let updated = { ...p, [`${gw}_${statKey}`]: finalValue };
+ if (statKey === 'xMins') updated[`${gw}_Pts`] = calculatedPts;
+ return updated;
+ }
+ return p;
+ }));
+
+ // 2. The Smart Self-Cleaning Session Edits
+ setSessionEdits(prev => {
+ const newEdits = { ...prev };
+
+ if (isRevertingToOriginal) {
+ if (newEdits[playerId]) {
+ newEdits[playerId] = { ...newEdits[playerId] };
+ delete newEdits[playerId][`${gw}_${statKey}`];
+ if (statKey === 'xMins') delete newEdits[playerId][`${gw}_Pts`];
+
+ if (Object.keys(newEdits[playerId]).length === 0) delete newEdits[playerId];
+ }
+ } else {
+ if (!newEdits[playerId]) newEdits[playerId] = {};
+ newEdits[playerId] = { ...newEdits[playerId], [`${gw}_${statKey}`]: finalValue };
+
+ if (statKey === 'xMins') {
+ // Do not hardcode match points so the stochastic engine can dynamically scale it!
+ if (!isMatchId) newEdits[playerId][`${gw}_Pts`] = calculatedPts;
+ }
+ }
+
+ if (isLoggedIn) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('fpl_token')}` },
+ body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverridesRef.current } })
+ });
+ }
+ return newEdits;
+ });
+ };
+
+ // --- STOCHASTIC ENGINE INVALIDATION ---
+ // If the user changes any fixture odds, the current solver lineup is now mathematically invalid.
+ // This instantly clears the stale lineup and forces the UI to reset.
+ useEffect(() => {
+ if (solverResult) {
+ setSolverResult(null);
+ }
+ if (appliedPlanSummary) {
+ setAppliedPlanSummary(null);
+ }
+ }, [fixtureOverrides]);
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg
new file mode 100644
index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184
--- /dev/null
+++ b/frontend/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg
new file mode 100644
index 0000000000000000000000000000000000000000..5101b674df391399da71c767aa5c976426c9dc7a
--- /dev/null
+++ b/frontend/src/assets/vite.svg
@@ -0,0 +1 @@
+Vite
diff --git a/frontend/src/components/AccuracyDashboard.jsx b/frontend/src/components/AccuracyDashboard.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b68b2416bfafc54e2af2936b553a97fcf11a17ac
--- /dev/null
+++ b/frontend/src/components/AccuracyDashboard.jsx
@@ -0,0 +1,273 @@
+import React, { useState, useEffect } from 'react';
+import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, ScatterChart, Scatter } from 'recharts';
+
+export default function AccuracyDashboard() {
+ const [activeTab, setActiveTab] = useState('match outcome');
+ const [matchData, setMatchData] = useState([]);
+ const [playerData, setPlayerData] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ Promise.all([
+ fetch('https://anayshukla-fpl-solver.hf.space/api/accuracy/matches').then(res => res.json()),
+ fetch('https://anayshukla-fpl-solver.hf.space/api/accuracy/players').then(res => res.json())
+ ]).then(([matches, players]) => {
+ setMatchData(matches);
+ setPlayerData(players);
+ setIsLoading(false);
+ });
+ }, []);
+
+ if (isLoading) return Loading Model Diagnostics...
;
+
+ // --- MATH HELPERS ---
+ const calcMetrics = (y_true, y_pred) => {
+ const n = y_true.length;
+ if (n === 0) return { rmse: 0, mae: 0, r2: 0 };
+
+ let sumErrSq = 0, sumErrAbs = 0, sumY = 0;
+ for (let i = 0; i < n; i++) {
+ sumErrSq += Math.pow(y_true[i] - y_pred[i], 2);
+ sumErrAbs += Math.abs(y_true[i] - y_pred[i]);
+ sumY += y_true[i];
+ }
+ const meanY = sumY / n;
+ let ssTot = 0;
+ for (let i = 0; i < n; i++) ssTot += Math.pow(y_true[i] - meanY, 2);
+
+ return {
+ rmse: Math.sqrt(sumErrSq / n),
+ mae: sumErrAbs / n,
+ r2: ssTot === 0 ? 0 : 1 - (sumErrSq / ssTot)
+ };
+ };
+
+ // --- OUTCOME ACCURACY MATH ---
+ let globalLL = 0, globalBrier = 0;
+ const trendData = [];
+
+ // --- GOALS & XG MATH ---
+ const actGoals = [], projGoals = [], actXG = [];
+ const scatterDataGoals = [], scatterDataXG = [];
+
+ if (matchData.length > 0) {
+ let totalMatches = 0;
+ const gwMap = matchData.reduce((acc, row) => {
+ (acc[row.GW] = acc[row.GW] || []).push(row);
+ return acc;
+ }, {});
+
+ Object.keys(gwMap).sort((a,b) => a-b).forEach(gw => {
+ const matches = gwMap[gw];
+ let gwLL = 0, gwBrier = 0;
+
+ matches.forEach(m => {
+ // Log loss
+ const p_h = Math.max(Math.min(m.home_win_prob, 1-1e-15), 1e-15);
+ const p_d = Math.max(Math.min(m.draw_prob, 1-1e-15), 1e-15);
+ const p_a = Math.max(Math.min(m.away_win_prob, 1-1e-15), 1e-15);
+
+ const ll = - ((m.home_win * Math.log(p_h)) + (m.draw * Math.log(p_d)) + (m.away_win * Math.log(p_a)));
+ gwLL += ll; globalLL += ll;
+
+ // Brier Score
+ 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);
+ gwBrier += brier; globalBrier += brier;
+ totalMatches++;
+
+ // Goals & xG Collections
+ if (m.home_goals !== undefined && m.expected_home_goals !== undefined) {
+ actGoals.push(Number(m.home_goals));
+ projGoals.push(Number(m.expected_home_goals));
+ actXG.push(Number(m.xg_home));
+ scatterDataGoals.push({ proj: Number(m.expected_home_goals), act: Number(m.home_goals) });
+ scatterDataXG.push({ proj: Number(m.expected_home_goals), act: Number(m.xg_home) });
+
+ actGoals.push(Number(m.away_goals));
+ projGoals.push(Number(m.expected_away_goals));
+ actXG.push(Number(m.xg_away));
+ scatterDataGoals.push({ proj: Number(m.expected_away_goals), act: Number(m.away_goals) });
+ scatterDataXG.push({ proj: Number(m.expected_away_goals), act: Number(m.xg_away) });
+ }
+ });
+
+ trendData.push({ gw: `GW${gw}`, weeklyBrier: gwBrier / matches.length, cumBrier: globalBrier / totalMatches });
+ });
+ globalLL /= totalMatches;
+ globalBrier /= totalMatches;
+ }
+
+ const goalsMetrics = calcMetrics(actGoals, projGoals);
+ const xgMetrics = calcMetrics(actXG, projGoals);
+
+ // --- xMINS / xPTS MATH ---
+ let ptsMetrics = { rmse: 0, mae: 0, r2: 0 };
+ let minsMetrics = { rmse: 0, mae: 0, r2: 0 };
+
+ if (playerData.length > 0) {
+ const actPts = [], pPts = [], actMins = [], pMins = [];
+ playerData.forEach(p => {
+ for (let gw = 1; gw <= 38; gw++) {
+ if (p[`${gw}_xMins`] > 0 && p[`${gw}_Pts`] !== undefined && p[`${gw}_Actuals`] !== undefined) {
+ pPts.push(Number(p[`${gw}_Pts`]));
+ actPts.push(Number(p[`${gw}_Actuals`]));
+ pMins.push(Number(p[`${gw}_xMins`]));
+ actMins.push(Number(p[`${gw}_Mins`]) || 0);
+ }
+ }
+ });
+ ptsMetrics = calcMetrics(actPts, pPts);
+ minsMetrics = calcMetrics(actMins, pMins);
+ }
+
+ return (
+
+ {/* TABS */}
+
+ {['match outcome', 'goals & xg', 'player_projections'].map(tab => (
+ setActiveTab(tab)}
+ 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'}`}
+ >
+ {tab.replace('_', ' ')}
+
+ ))}
+
+
+ {/* TAB 1: OUTCOME */}
+ {activeTab === 'match outcome' && (
+
+
+
+
Multi-class Log Loss
+
{globalLL.toFixed(4)}
+
+
+
Multi-class Brier Score
+
{globalBrier.toFixed(4)}
+
+
+
+
+
Brier Score Trend (Lower is Better)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* TAB 2: GOALS & XG */}
+ {activeTab === 'goals & xg' && (
+
+
+
+ {/* GOALS VS PROJ */}
+
+
Goals v/s Projected
+
+
RMSE {goalsMetrics.rmse.toFixed(3)}
+
MAE {goalsMetrics.mae.toFixed(3)}
+
+
+
+
+ {/* XG VS PROJ */}
+
+
xG v/s Projected
+
+
RMSE {xgMetrics.rmse.toFixed(3)}
+
MAE {xgMetrics.mae.toFixed(3)}
+
+
+
+
+
+
+ )}
+
+ {/* TAB 3: PLAYER STATS */}
+ {activeTab === 'player_projections' && (
+
+
+
+
+ {/* Updated Title */}
+
xMins Accuracy (0< xMins)
+
+
+
R2 Score
+
{minsMetrics.r2.toFixed(3)}
+
+
+
MAE
+
{minsMetrics.mae.toFixed(3)}
+
+
+
RMSE
+
{minsMetrics.rmse.toFixed(3)}
+
+
+
+
+
+ {/* Updated Title */}
+
xPts Accuracy (0< xPts)
+
+
+
R2 Score
+
{ptsMetrics.r2.toFixed(3)}
+
+
+
MAE
+
{ptsMetrics.mae.toFixed(3)}
+
+
+
RMSE
+
{ptsMetrics.rmse.toFixed(3)}
+
+
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/ActiveMovesPanel.jsx b/frontend/src/components/ActiveMovesPanel.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..538768ec718c9b0fe7931cb147b19d8bea66d721
--- /dev/null
+++ b/frontend/src/components/ActiveMovesPanel.jsx
@@ -0,0 +1,94 @@
+import React from "react";
+import { ArrowRightLeft } from "lucide-react";
+import { CHIP_CONFIG, getPlayerPrice } from "../utils/fplLogic";
+
+export const ActiveMovesPanel = ({
+ activeGW,
+ manualOverrides,
+ globalPlayers,
+ chipsByGw,
+ transfersByGw
+}) => {
+ const activeLock = manualOverrides[activeGW] || {};
+ const manualTransfers = activeLock.manualTransfers || {};
+ const chip = chipsByGw[activeGW];
+ const tData = transfersByGw[activeGW] || { count: 0, netDelta: 0 };
+
+ const moveEntries = Object.entries(manualTransfers);
+ const hasMoves = moveEntries.length > 0;
+
+ return (
+
+
+ {/* Header */}
+
+
+
+ GW {activeGW} Moves Made So Far
+
+ {chip && CHIP_CONFIG[chip] && (
+
+ {CHIP_CONFIG[chip].short}
+
+ )}
+
+
+ {/* Body */}
+
+ {!hasMoves ? (
+
+ No active transfers made for GW {activeGW}.
+
+ ) : (
+ moveEntries.map(([inIdStr, outPlayer], idx) => {
+ const isBlankIn = inIdStr.startsWith("blank_");
+ const inPlayer = !isBlankIn ? globalPlayers.find(p => String(p.ID) === inIdStr) : null;
+
+ return (
+
+
+ {/* Outgoing Player */}
+
+ {outPlayer?.Name || "Unknown"}
+ £{(outPlayer ? getPlayerPrice(outPlayer) : 0).toFixed(1)}m
+
+
+
»
+
+ {/* Incoming Player */}
+
+ {isBlankIn ? (
+ <>
+ Select Player...
+ >
+ ) : (
+ <>
+ {inPlayer?.Name || inIdStr}
+ £{(inPlayer ? getPlayerPrice(inPlayer) : 0).toFixed(1)}m
+ >
+ )}
+
+
+
+ );
+ })
+ )}
+
+ {/* Summary Footer */}
+ {hasMoves && (
+
+
+ Total Moves: {tData.count}
+
+
+ Bank Delta:
+ = 0 ? 'text-emerald-400' : 'text-red-400'}`}>
+ {tData.netDelta > 0 ? '+' : ''}{tData.netDelta.toFixed(1)}m
+
+
+
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/AdvancedSettingsModal.jsx b/frontend/src/components/AdvancedSettingsModal.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f7fdbca394cf68ed614fb016f3d1f850572a8f9c
--- /dev/null
+++ b/frontend/src/components/AdvancedSettingsModal.jsx
@@ -0,0 +1,254 @@
+import React, { useState } from "react";
+import { X, Power, Info, RotateCcw } from "lucide-react";
+
+// The absolute baseline FPL defaults as defined in your comprehensive_settings.json
+export const DEFAULT_SETTINGS = {
+ enabled: false,
+ secs: 600,
+ hit_limit: 0,
+ no_transfer_last_gws: 0,
+ keep_top_ev_percent: 7,
+ ft_use_penalty: 0.1,
+ itb_loss_per_transfer: 0.0,
+ vcap_weight: 0.1,
+ opposing_play_penalty: 0.5,
+ use_ft_value_list: false, // Sub-toggle for FT behavior
+ ft_value: 1.5,
+ xmin_lb: 45,
+ ft_value_list: { "2": 2, "3": 1.6, "4": 1.3, "5": 1.1 },
+ bench_weights: { "0": 0.03, "1": 0.21, "2": 0.06, "3": 0.002 },
+ randomization_strength: 1.0,
+ iteration_criteria: "this_gw_transfer_in_out",
+ iteration_diff: 2,
+};
+
+export function AdvancedSettingsModal({
+ setShowAdvancedSettings,
+ comprehensiveSettings,
+ setComprehensiveSettings,
+}) {
+ const [isEnabled, setIsEnabled] = useState(
+ comprehensiveSettings.enabled ?? false
+ );
+
+ const handleToggle = () => {
+ const newState = !isEnabled;
+ setIsEnabled(newState);
+ setComprehensiveSettings((prev) => ({ ...prev, enabled: newState }));
+ };
+
+ const handleResetToDefaults = () => {
+ if (window.confirm("Are you sure you want to restore all advanced settings to their recommended defaults?")) {
+ // Restore all defaults, but preserve whatever the current toggle state is!
+ setComprehensiveSettings({ ...DEFAULT_SETTINGS, enabled: isEnabled });
+ }
+ };
+
+ const handleChange = (key, value, nestedKey = null) => {
+ setComprehensiveSettings((prev) => {
+ if (nestedKey !== null) {
+ return {
+ ...prev,
+ [key]: {
+ ...(prev[key] || {}),
+ [nestedKey]: value,
+ },
+ };
+ }
+ return { ...prev, [key]: value };
+ });
+ };
+
+ // Check if we are using the dynamic list or the flat value
+ const isUsingFtList = comprehensiveSettings.use_ft_value_list ?? DEFAULT_SETTINGS.use_ft_value_list;
+
+ const SETTINGS_GROUPS = [
+ {
+ title: "Solver Constraints",
+ items: [
+ { 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." },
+ { 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." },
+ { 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." },
+ { 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." },
+ { 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." },
+ ]
+ },
+ {
+ title: "Penalties & Weights",
+ items: [
+ { 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)." },
+ { 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." },
+ { 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." },
+ { 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." },
+ ]
+ },
+ {
+ title: "Bench Weights",
+ desc: "Fractional EV added to bench players based on their bench order.",
+ isNested: true,
+ parentKey: "bench_weights",
+ items: [
+ { nestedKey: "0", label: "Goalkeeper", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["0"] },
+ { nestedKey: "1", label: "Outfield 1st", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["1"] },
+ { nestedKey: "2", label: "Outfield 2nd", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["2"] },
+ { nestedKey: "3", label: "Outfield 3rd", type: "number", step: "0.01", default: DEFAULT_SETTINGS.bench_weights["3"] },
+ ]
+ },
+ {
+ title: "Iterations & Simulations",
+ items: [
+ { 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." },
+ { key: "iteration_criteria", label: "Iteration Criteria", type: "select", default: DEFAULT_SETTINGS.iteration_criteria, desc: "Rule to generate alternative solutions in subsequent iterations.",
+ options: [
+ { value: "this_gw_transfer_in_out", label: "Transfers In & Out (Current GW)" },
+ { value: "this_gw_transfer_in", label: "Transfers In (Current GW)" },
+ { value: "this_gw_transfer_out", label: "Transfers Out (Current GW)" },
+ ]
+ },
+ { 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." }
+ ]
+ }
+ ];
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+ Advanced Algorithm Settings
+
+
Configure comprehensive internal MILP parameters and weights.
+
+
+
+ Defaults
+
+ setShowAdvancedSettings(false)} className="text-slate-500 hover:text-white transition-colors bg-slate-900 p-2 rounded-lg border border-slate-800">
+
+
+
+
+
+ {/* Body */}
+
+
+ {/* Master Toggle */}
+
+
+
+
+
Enable Advanced Overrides
+
When enabled, these parameters will be injected into the solver payload.
+
+
+
+
+
+
+
+
+
+ {/* SPECIAL SECTION: Free Transfer Valuation */}
+
+
+
+
FT Val
+
Intrinsic EV value assigned for holding/rolling free transfers.
+
+
+ Dynamic List
+ 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'}`}>
+
+
+
+
+
+ {isUsingFtList ? (
+
+ {["2", "3", "4", "5"].map((num) => (
+
+ Value of {num}{num==='2'?'nd':num==='3'?'rd':'th'} FT
+ 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" />
+
+ ))}
+
+ ) : (
+
+
Using standard flat FT Value from normal settings.
+
+ )}
+
+
+ {/* Render Remaining Standard Settings Groups */}
+ {SETTINGS_GROUPS.map((group, idx) => (
+
+
+
{group.title}
+ {group.desc &&
{group.desc}
}
+
+
+
+ {group.items.map((item) => {
+ let val = group.isNested
+ ? (comprehensiveSettings[group.parentKey]?.[item.nestedKey] ?? item.default)
+ : (comprehensiveSettings[item.key] ?? item.default);
+
+ return (
+
+
+ {item.label}
+
+
+
+ {item.type === "select" ? (
+
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">
+ {item.options.map(opt => {opt.label} )}
+
+ ) : (
+
{
+ // THE FIX 2: Safely extract boolean for checkboxes, numbers for everything else
+ let newVal;
+ if (item.type === "checkbox") {
+ newVal = e.target.checked;
+ } else {
+ newVal = e.target.value === "" ? "" : (parseFloat(e.target.value) || 0);
+ }
+
+ group.isNested ? handleChange(group.parentKey, newVal, item.nestedKey) : handleChange(item.key, newVal);
+ }}
+ 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'}`}
+ />
+ )}
+
+
+ {item.desc || group.desc}
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/DraftsComparisonTable.jsx b/frontend/src/components/DraftsComparisonTable.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..bc641897ce42a19cc0adf2103131c478bfd84706
--- /dev/null
+++ b/frontend/src/components/DraftsComparisonTable.jsx
@@ -0,0 +1,140 @@
+import React from "react";
+import { Trash2 } from "lucide-react";
+
+export const DraftsComparisonTable = ({
+ drafts, horizonGWs, activeDraftId, globalPlayers,
+ setActiveDraftId, getValidLayout, availableGWs,
+ baselineFt, ftAtStartOfGw, setDrafts
+}) => {
+ if (!drafts || drafts.length === 0) return null;
+
+ const handleDeleteDraft = (e, id) => {
+ e.stopPropagation(); // Prevents the row click from triggering
+ if (drafts.length <= 1) return;
+ const nextDrafts = drafts.filter(d => d.id !== id);
+ setDrafts(nextDrafts);
+ if (activeDraftId === id) setActiveDraftId(nextDrafts[0].id);
+ };
+
+ const getDraftGwState = (draft, gw) => {
+ if (draft.cachedEvs && draft.cachedEvs[gw]) {
+ return draft.cachedEvs[gw];
+ }
+ const chip = draft.chipsByGw?.[gw];
+ const capMult = chip === "tc" ? 3 : 2;
+ let squad = [];
+ let capId = null;
+
+ if (gw === draft.activeGW) {
+ squad = draft.teamData;
+ capId = draft.captainId;
+ } else {
+ const lock = draft.manualOverrides?.[gw];
+ if (lock && lock.ids && lock.ids.length === 15) {
+ squad = lock.ids.map(id => draft.teamData?.find(p => String(p.ID) === String(id)) || globalPlayers.find(p => String(p.ID) === String(id))).filter(Boolean);
+ capId = lock.cap;
+ if (squad.length !== 15) {
+ const opt = getValidLayout(draft.teamData, gw);
+ if (opt) { squad = opt.optimalArray; capId = opt.cap; }
+ }
+ } else {
+ const opt = getValidLayout(draft.teamData, gw);
+ if (opt) { squad = opt.optimalArray; capId = opt.cap; }
+ }
+ }
+
+ let gwPts = 0;
+ if (squad && squad.length === 15) {
+ squad.slice(0, 11).forEach(p => {
+ if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (String(p.ID) === String(capId) ? capMult : 1);
+ });
+ let ofIdx = 0;
+ squad.slice(11, 15).forEach(p => {
+ if (!p.isBlank) {
+ if (chip === "bb") gwPts += (Number(p[`${gw}_Pts`]) || 0);
+ else if (p.Pos === "G") gwPts += (Number(p[`${gw}_Pts`]) || 0) * 0.04;
+ else { gwPts += (Number(p[`${gw}_Pts`]) || 0) * ([0.17, 0.05, 0.02][ofIdx] || 0.02); ofIdx++; }
+ }
+ });
+ }
+
+ const ftStart = ftAtStartOfGw(gw, availableGWs, baselineFt, draft.transfersByGw || {}, draft.chipsByGw || {});
+ const moves = draft.transfersByGw?.[gw]?.count || 0;
+ const isChipFree = chip === "wc" || chip === "fh";
+ const hits = isChipFree ? 0 : Math.max(0, moves - ftStart);
+
+ return { ev: gwPts - (hits * 4), chip, hits, ftStart, moves, isChipFree };
+ };
+
+ return (
+
+
+
+
+
+ Timeline
+ {horizonGWs.map(gw => (
+ GW{gw}
+ ))}
+ Total EV
+
+
+
+ {drafts.map((draft) => {
+ const isActive = draft.id === activeDraftId;
+ const rowData = horizonGWs.map(gw => getDraftGwState(draft, gw));
+ const totalEv = rowData.reduce((sum, d) => sum + d.ev, 0);
+
+ return (
+ setActiveDraftId(draft.id)}
+ className={`transition-all cursor-pointer group ${isActive ? "bg-[#1e2247]/60" : "bg-[#0a0f1c] hover:bg-[#151833]"}`}
+ >
+
+
+
+ {draft.name}
+
+ {/* FIX: Native Flexbox Delete Button (always clickable, visually clean) */}
+ {drafts.length > 1 && (
+
handleDeleteDraft(e, draft.id)}
+ className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-500/10 rounded-md transition-all flex-shrink-0"
+ title="Delete Timeline"
+ >
+
+
+ )}
+
+
+
+ {rowData.map((d, i) => (
+
+ 55 ? 'text-[#818cf8]' : 'text-indigo-200') : 'text-slate-500'}`}>
+ {d.ev.toFixed(1)}
+
+
+ {d.chip || ""}
+
+ {d.hits > 0 ? `-${d.hits*4}` : "-"}
+ {d.isChipFree ? `${d.moves}/∞` : `${d.moves}/${d.ftStart}`}
+
+
+
+ ))}
+
+
+
+ {totalEv.toFixed(1)}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/DraggablePlayer.jsx b/frontend/src/components/DraggablePlayer.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ba93482baa33ff6f0ab839b635ed0dda05feee28
--- /dev/null
+++ b/frontend/src/components/DraggablePlayer.jsx
@@ -0,0 +1,72 @@
+import React from "react";
+import { useDraggable, useDroppable } from "@dnd-kit/core";
+import { CSS } from "@dnd-kit/utilities";
+import { PlayerCardVisual } from "./PlayerCardVisual"; // Import the visual component we just made
+
+export const DraggablePlayer = ({
+ player,
+ isBench,
+ benchIndex,
+ isActiveDrag,
+ isValidTarget,
+ captainId,
+ viceId,
+ handleCapChange,
+ playerCardGWs,
+ fixtures,
+ activeGW,
+ onPlayerClick,
+ onUndo,
+ isHighlighted,
+ onSolverUndo,
+ activeChipType,
+}) => {
+ const disableBenchGkDrag = Boolean(
+ isBench && benchIndex === 0 && player.Pos === "G" && !player.isBlank,
+ );
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
+ useDraggable({
+ id: player.ID,
+ data: { player, isBench },
+ disabled: Boolean(player.isBlank),
+ });
+ const { setNodeRef: setDropRef, isOver } = useDroppable({
+ id: player.ID,
+ data: { player, isBench },
+ });
+
+ const style = {
+ transform: CSS.Translate.toString(transform),
+ zIndex: isDragging ? 50 : 20,
+ opacity: isDragging ? 0.6 : isActiveDrag && !isValidTarget ? 0.3 : 1,
+ filter: isOver && isValidTarget && !isDragging ? "brightness(1.5)" : "none",
+ };
+
+ return (
+ {
+ setNodeRef(node);
+ setDropRef(node);
+ }}
+ style={style}
+ {...listeners}
+ {...attributes}
+ className={`touch-none select-none rounded-xl transition-[box-shadow,filter] duration-200 ${isHighlighted ? "transfer-highlight-card ring-2 ring-cyan-400/40" : ""}`}
+ >
+
onPlayerClick(player)}
+ onUndo={onUndo}
+ onSolverUndo={onSolverUndo}
+ activeChipType={activeChipType}
+ />
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/FixtureMatrixPanel.jsx b/frontend/src/components/FixtureMatrixPanel.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..b99567df7be855826a0b326b835b5bc250080885
--- /dev/null
+++ b/frontend/src/components/FixtureMatrixPanel.jsx
@@ -0,0 +1,375 @@
+import React, { useMemo, useState, useRef, useContext } from "react";
+import { Plus, Trash2, Zap, Search, X, Database } from "lucide-react";
+import { PlayerContext } from '../PlayerContext';
+
+export const FixtureMatrixPanel = ({
+ globalPlayers,
+ fixtureOverrides,
+ setFixtureOverrides,
+ availableGWs
+}) => {
+ const { globalFixtures = {}, effectiveFixtures = {} } = useContext(PlayerContext);
+ const [search, setSearch] = useState("");
+
+ // --- ADMIN BACKDOOR STATE ---
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [adminPassword, setAdminPassword] = useState('');
+ const [showAdminLogin, setShowAdminLogin] = useState(false);
+ const [clickCount, setClickCount] = useState(0);
+ const clickTimeoutRef = useRef(null);
+
+ const handleSecretClick = () => {
+ setClickCount((prev) => {
+ const newCount = prev + 1;
+ if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; }
+ return newCount;
+ });
+ if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current);
+ clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000);
+ };
+
+ const handlePublishGlobal = async () => {
+ if (!window.confirm("WARNING: This will overwrite the live FPL database for ALL users. Proceed?")) return;
+
+ try {
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/update', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ is_admin: isAdmin, admin_password: adminPassword,
+ overrides: { ...globalFixtures, ...fixtureOverrides } // Merges admin edits with existing globals
+ })
+ });
+ if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend publish failed'); }
+ alert("Success! Global fixtures updated. All users will see these on refresh.");
+ } catch (err) { console.error("Publish error:", err); }
+ };
+
+ // 1. Extract all matches
+ const allMatches = useMemo(() => {
+ const TEAM_SHORTS = {
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
+ };
+
+ const matchMap = new Map();
+ globalPlayers.forEach(p => {
+ if (p.match_projections) {
+ Object.entries(p.match_projections).forEach(([matchId, data]) => {
+ if (!matchMap.has(matchId)) {
+ const [homeId, awayId] = matchId.split("_vs_");
+ const hName = TEAM_SHORTS[homeId] || homeId;
+ const aName = TEAM_SHORTS[awayId] || awayId;
+
+ matchMap.set(matchId, {
+ id: matchId,
+ homeTeam: hName,
+ awayTeam: aName,
+ defaultGw: data.default_gw,
+ searchString: `${hName} ${aName}`.toLowerCase()
+ });
+ }
+ });
+ }
+ });
+ return Array.from(matchMap.values()).sort((a, b) => a.defaultGw - b.defaultGw);
+ }, [globalPlayers]);
+
+ const activeSplits = allMatches.filter(m => effectiveFixtures[m.id]);
+ const searchResults = search
+ ? allMatches.filter(m => !effectiveFixtures[m.id] && m.searchString.includes(search.toLowerCase())).slice(0, 10)
+ : [];
+
+ // --- HANDLERS ---
+ const handleAddOverride = (match) => {
+ // THE FIX 4b: If they search a hidden global fixture, load its true splits! Otherwise default to 100%.
+ const initialSplit = globalFixtures[match.id] ? { ...globalFixtures[match.id] } : { [match.defaultGw]: 1.0 };
+ setFixtureOverrides(prev => ({ ...prev, [match.id]: initialSplit }));
+ setSearch("");
+ };
+
+ // 1. Create a "Blank" Split Row
+ const handleAddSplitGw = (matchId) => {
+ setFixtureOverrides(prev => {
+ const next = { ...prev };
+ const tempId = `unselected_${Date.now()}`;
+ next[matchId] = { ...next[matchId], [tempId]: 0.0 };
+ return next;
+ });
+ };
+
+ // Convert the "Blank" row into a real GW when user selects from dropdown
+ const handleChangeSplitGw = (matchId, oldGw, newGw) => {
+ setFixtureOverrides(prev => {
+ const next = { ...prev };
+ const matchOverrides = { ...next[matchId] };
+ const prob = matchOverrides[oldGw];
+ delete matchOverrides[oldGw];
+ matchOverrides[newGw] = prob;
+ next[matchId] = matchOverrides;
+ return next;
+ });
+ };
+
+ // 2. The Auto-Balancer Engine (Always enforces 100% sum)
+ const handleUpdateSplit = (matchId, gw, newProbRaw) => {
+ setFixtureOverrides(prev => {
+ const next = { ...prev };
+ const matchOverrides = { ...next[matchId] };
+
+ let newProb = Math.min(Math.max(parseFloat(newProbRaw), 0), 1);
+ const oldProb = matchOverrides[gw] || 0;
+ let diff = newProb - oldProb;
+
+ // Find all OTHER gameweeks that are already fully configured (ignoring blanks)
+ const otherGws = Object.keys(matchOverrides).filter(k => k !== String(gw) && !k.startsWith('unselected'));
+
+ if (otherGws.length > 0 && diff !== 0) {
+ if (otherGws.length === 1) {
+ // If 2 total GWs, modifying one perfectly scales the other
+ let otherProb = matchOverrides[otherGws[0]] - diff;
+ otherProb = Math.min(Math.max(otherProb, 0), 1);
+ matchOverrides[otherGws[0]] = otherProb;
+ newProb = 1 - otherProb;
+ } else {
+ // If 3+ GWs, distribute the remainder proportionally
+ let sumOthers = otherGws.reduce((acc, key) => acc + matchOverrides[key], 0);
+ if (sumOthers === 0) {
+ matchOverrides[otherGws[0]] = 1 - newProb;
+ } else {
+ const targetOthersSum = 1 - newProb;
+ otherGws.forEach(k => {
+ matchOverrides[k] = (matchOverrides[k] / sumOthers) * targetOthersSum;
+ });
+ }
+ }
+ }
+
+ matchOverrides[gw] = newProb;
+ next[matchId] = matchOverrides;
+ return next;
+ });
+ };
+
+ // Deleting a row gives its probability to the remaining rows
+ const handleRemoveSplit = (matchId, gw) => {
+ setFixtureOverrides(prev => {
+ const next = { ...prev };
+ const matchOverrides = { ...next[matchId] };
+ const deletedProb = matchOverrides[gw] || 0;
+ delete matchOverrides[gw];
+
+ const remaining = Object.keys(matchOverrides).filter(k => !k.startsWith('unselected'));
+ if (remaining.length > 0 && deletedProb > 0) {
+ if (remaining.length === 1) {
+ matchOverrides[remaining[0]] += deletedProb;
+ } else {
+ let sumRem = remaining.reduce((a, k) => a + matchOverrides[k], 0);
+ if(sumRem === 0) {
+ matchOverrides[remaining[0]] = 1.0;
+ } else {
+ remaining.forEach(k => {
+ matchOverrides[k] += (matchOverrides[k] / sumRem) * deletedProb;
+ });
+ }
+ }
+ }
+
+ if (Object.keys(matchOverrides).length === 0) delete next[matchId];
+ else next[matchId] = matchOverrides;
+
+ return next;
+ });
+ };
+
+ const handleRemoveEntireOverride = (matchId) => {
+ setFixtureOverrides(prev => {
+ const next = { ...prev };
+ delete next[matchId];
+ return next;
+ });
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+ Override schedules & EV splits. Only customized fixtures are displayed below.
+
+ {Object.keys(fixtureOverrides).length > 0 && (
+
{ 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">
+ Reset All
+
+ )}
+
+
+ {/* Fixture Search Bar & Admin Tools */}
+
+
+ {/* Secret Click Zone */}
+
+
+
+
+
setSearch(e.target.value)}
+ className="w-full bg-transparent py-2 pl-6 text-xs font-bold text-slate-200 outline-none"
+ />
+ {search &&
setSearch("")} className="text-slate-500 hover:text-white z-10"> }
+
+
+ {/* Admin Login UI */}
+ {showAdminLogin && !isAdmin && (
+
+ 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" />
+ setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-xs text-white transition-colors">Login
+
+ )}
+
+ {isAdmin && (
+
+ Publish Globally
+
+ )}
+
+ {/* Search Results Dropdown */}
+ {search && (
+
+ {searchResults.length === 0 ? (
+
No matches found...
+ ) : (
+ searchResults.map(match => (
+
handleAddOverride(match)}
+ className="w-full flex items-center justify-between p-3 border-b border-slate-700/50 hover:bg-slate-700 transition-colors text-left"
+ >
+
+ {match.homeTeam}
+ vs
+ {match.awayTeam}
+
+ GW{match.defaultGw}
+
+ ))
+ )}
+
+ )}
+
+
+ {/* Active Overrides List */}
+
+ {activeSplits.length === 0 ? (
+
+
+ No Active Overrides
+
+ ) : (
+ activeSplits.map(match => {
+ const overrides = effectiveFixtures[match.id];
+
+ return (
+
+
+ {/* Match Header */}
+
+
+ {match.homeTeam}
+ vs
+ {match.awayTeam}
+
+
handleRemoveEntireOverride(match.id)} className="text-slate-500 hover:text-red-400 transition-colors" title="Remove Override">
+
+
+
+
+ {/* Sliders / Override UI */}
+
+ {Object.entries(overrides).map(([gw, prob]) => {
+ // Check if this row is an empty placeholder waiting for a selection
+ const isUnselected = gw.startsWith('unselected');
+
+ return (
+
+
handleChangeSplitGw(match.id, gw, e.target.value)}
+ 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'}`}
+ >
+ {isUnselected && Select GW }
+ {availableGWs.map(g => {
+ // Don't show gameweeks that are already selected in another row for this match
+ const isAlreadySelected = Object.keys(overrides).includes(String(g)) && String(g) !== String(gw);
+ return !isAlreadySelected && GW{g} ;
+ })}
+
+
+ {/* 1% Step Sliders, locked if no GW is selected */}
+
handleUpdateSplit(match.id, gw, e.target.value)}
+ className={`flex-1 accent-indigo-500 ${isUnselected ? 'opacity-30 cursor-not-allowed' : 'cursor-ew-resize'}`}
+ />
+
+ {/* Editable Number Input with rounding and boundaries */}
+
+ {
+ let val = Math.round(Number(e.target.value));
+ if (isNaN(val)) val = 0;
+ if (val > 100) val = 100;
+ if (val < 0) val = 0;
+ handleUpdateSplit(match.id, gw, val / 100);
+ }}
+ 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'}`}
+ />
+ %
+
+
+
handleRemoveSplit(match.id, gw)} className="text-slate-600 hover:text-red-400 p-1">
+
+
+
+ );
+ })}
+
+ {/* Footer: Add Row & EV Sum Validator */}
+
+
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">
+ Add GW Split
+
+
+ {(() => {
+ const totalProb = Object.values(overrides).reduce((a, b) => a + b, 0);
+ const isBalanced = Math.abs(totalProb - 1.0) < 0.01;
+ return (
+
+ {isBalanced ? : null}
+ Total: {Math.round(totalProb * 100)}%
+
+ );
+ })()}
+
+
+
+ );
+ })
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/Fixtures.jsx b/frontend/src/components/Fixtures.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..06b100d576b8354482d1395875894afa2d9b7ff7
--- /dev/null
+++ b/frontend/src/components/Fixtures.jsx
@@ -0,0 +1,143 @@
+import React, { useState, useEffect, useContext } from 'react';
+import { getShortName } from '../utils/teams';
+import { PlayerContext } from '../PlayerContext';
+
+export default function Fixtures() {
+ const [fixtures, setFixtures] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures')
+ .then(res => res.json())
+ .then(data => {
+ setFixtures(data);
+ setIsLoading(false);
+ });
+ }, []);
+
+ if (isLoading) return Loading fixtures...
;
+
+ const { effectiveFixtures } = useContext(PlayerContext);
+
+ const TEAM_MAP = {
+ "Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "AFC Bournemouth": 4, "Brentford": 5,
+ "Brighton": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10,
+ "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13,
+ "Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15,
+ "Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17,
+ "Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18,
+ "West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20
+ };
+
+ const expandedFixtures = [];
+
+ fixtures.forEach(match => {
+ const hId = match.home_team_id || TEAM_MAP[match.home_team] || match.home_team;
+ const aId = match.away_team_id || TEAM_MAP[match.away_team] || match.away_team;
+ const matchId = `${hId}_vs_${aId}`;
+
+ const override = effectiveFixtures?.[matchId];
+
+ if (override) {
+ Object.entries(override).forEach(([gw, prob]) => {
+ // THE FIX: Prevent floating point ghost fixtures (must be >= 0.5%)
+ if (Number(prob) >= 0.005) {
+ expandedFixtures.push({ ...match, GW: Number(gw), shiftProb: Number(prob) });
+ }
+ });
+ } else {
+ expandedFixtures.push({ ...match, shiftProb: 1.0 });
+ }
+ });
+
+ const groupedFixtures = expandedFixtures.reduce((acc, match) => {
+ (acc[match.GW] = acc[match.GW] || []).push(match);
+ return acc;
+ }, {});
+
+ return (
+
+ {Object.entries(groupedFixtures).map(([gw, matches]) => (
+
+
Gameweek {gw}
+
+
+ {matches.map((match, idx) => {
+
+ const hw = match.home_win_prob;
+ const aw = match.away_win_prob;
+ const isGhost = match.shiftProb < 0.995;
+
+ 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');
+ 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');
+
+ const ghostStyles = isGhost
+ ? "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)]"
+ : "border-slate-800/80 hover:border-slate-600";
+
+ return (
+
+ {isGhost && (
+
+ {Math.round(match.shiftProb * 100)}% Chance
+
+ )}
+
+ {/* Header: Teams & xG */}
+
+
+ {getShortName(match.home_team)}
+ {/* UPDATED: Larger, bolder xG text */}
+
+ {match.expected_home_goals.toFixed(2)} xG
+
+
+
+
vs
+
+
+ {getShortName(match.away_team)}
+ {/* UPDATED: Larger, bolder xG text */}
+
+ {match.expected_away_goals.toFixed(2)} xG
+
+
+
+
+ {/* Body: Probabilities & Clean Sheets */}
+
+
+
+ HOME WIN
+ {(match.home_win_prob * 100).toFixed(1)}%
+
+
+ DRAW
+ {(match.draw_prob * 100).toFixed(1)}%
+
+
+ AWAY WIN
+ {(match.away_win_prob * 100).toFixed(1)}%
+
+
+
+ {/* Clean Sheet Odds */}
+
+
Home CS: {(match.home_clean_sheet_odds * 100).toFixed(1)}%
+
Away CS: {(match.away_clean_sheet_odds * 100).toFixed(1)}%
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/LandingPage.jsx b/frontend/src/components/LandingPage.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2ad0ccfc0731b442f6f5499b31aa7d8b54b74abe
--- /dev/null
+++ b/frontend/src/components/LandingPage.jsx
@@ -0,0 +1,76 @@
+import React, { useState } from "react";
+import { Target, TrendingUp, Shield } from "lucide-react";
+import LoginModal from "./LoginModal"; // Fixed import syntax
+
+// Adjust these paths depending on exactly where your images are saved in your src folder!
+
+export const LandingPage = () => {
+ const [showLoginModal, setShowLoginModal] = useState(false);
+
+ return (
+
+
+ {/* Epic Mansion Background */}
+
+
+ {/* Gradient Overlay to ensure text remains highly readable */}
+
+
+
+
+ {/* Glowing Luigi Orb */}
+
+
+
+
+
+ Welcome to
+
+ Luigi's Mansion
+
+
+
+
+ Luigi's Mansion is here. New, improved, and simply Wieffertastic.
+
+
+
setShowLoginModal(true)}
+ 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"
+ >
+ Enter the Mansion
+
+
+ {/* Feature Highlights */}
+
+
+
+
In-built Solver
+
With a horizon of 10 gameweeks and 5 drafts to allow you to solve for different scenarios.
+
+
+
+
Editable Projections
+
Real-time projections adjustment based on your xMins inputs.
+
+
+
+
Cloud Synced
+
Your squad, manual edits, and settings are securely saved to your account.
+
+
+
+
+ {/* Render the actual Login Modal safely on top of EVERYTHING */}
+ {showLoginModal && (
+
+ {/* THE FIX: We must explicitly pass isOpen={true} to bypass the modal's internal null check */}
+ setShowLoginModal(false)} />
+
+ )}
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/LoginModal.jsx b/frontend/src/components/LoginModal.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..cd85058090e380aaf04c0a66b5bd06a12d09de6c
--- /dev/null
+++ b/frontend/src/components/LoginModal.jsx
@@ -0,0 +1,231 @@
+import React, { useState, useContext } from 'react';
+import { X, Mail, Lock, Loader2, Shield, Eye, EyeOff } from 'lucide-react';
+import { PlayerContext } from '../PlayerContext';
+import { GoogleLogin } from '@react-oauth/google';
+
+export default function LoginModal({ isOpen, onClose }) {
+ const { setIsLoggedIn, setUserProfile, setHasGuestMadeEdits } = useContext(PlayerContext);
+
+ const [isSignUp, setIsSignUp] = useState(false);
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [confirmPassword, setConfirmPassword] = useState('');
+
+ const [showPassword, setShowPassword] = useState(false);
+ const [showConfirmPassword, setShowConfirmPassword] = useState(false);
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState('');
+
+ if (!isOpen) return null;
+
+ const toggleMode = () => {
+ setIsSignUp(!isSignUp);
+ setError('');
+ setPassword('');
+ setConfirmPassword('');
+ };
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setIsLoading(true);
+ setError('');
+
+ // Reconfirmation Validation for Sign Up
+ if (isSignUp && password !== confirmPassword) {
+ setError('Passwords do not match!');
+ setIsLoading(false);
+ return;
+ }
+
+ const endpoint = isSignUp ? '/api/auth/register' : '/api/auth/login';
+
+ try {
+ const res = await fetch(`https://anayshukla-fpl-solver.hf.space${endpoint}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ email, password })
+ });
+
+ const data = await res.json();
+
+ if (!res.ok) {
+ throw new Error(data.detail || 'Authentication failed');
+ }
+
+ // Success! Save token and update Global Context
+ localStorage.setItem('fpl_token', data.access_token);
+
+ setUserProfile({
+ username: data.email.split('@')[0],
+ defaultTeamId: null,
+ isAdmin: data.is_admin
+ });
+
+ setIsLoggedIn(true);
+ setHasGuestMadeEdits(false);
+ onClose();
+
+ } catch (err) {
+ setError(err.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {isSignUp ? 'Create Account' : 'Welcome Back'}
+
+
+
+
+
+
+
+ {/* Body */}
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+ {/* Toggle Login/Signup Mode */}
+
+ {isSignUp ? 'Already have an account?' : "Don't have an account?"}
+
+ {isSignUp ? 'Log In' : 'Sign Up'}
+
+
+
+ {/* Elegant "OR" Divider */}
+
+
+ {/* Google Login Block */}
+
+ {
+ try {
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/auth/google', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: credentialResponse.credential })
+ });
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || "Google Auth Failed");
+
+ localStorage.setItem('fpl_token', data.access_token);
+ setUserProfile({
+ username: data.email.split('@')[0],
+ defaultTeamId: null,
+ isAdmin: data.is_admin
+ });
+ setIsLoggedIn(true);
+ setHasGuestMadeEdits(false);
+ onClose();
+ } catch (err) {
+ setError(err.message);
+ }
+ }}
+ onError={() => {
+ setError('Google Login window closed or failed.');
+ }}
+ theme="filled_black"
+ shape="pill"
+ />
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/PitchView.jsx b/frontend/src/components/PitchView.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..3ef5d5f1f00e65a570db636dbca4afbc7837d69a
--- /dev/null
+++ b/frontend/src/components/PitchView.jsx
@@ -0,0 +1,100 @@
+// src/components/PitchView.jsx
+import React from "react";
+import { DraggablePlayer } from "./DraggablePlayer";
+
+export const PitchView = ({
+ teamData,
+ activeDragPlayer,
+ isValidSwap,
+ captainId,
+ viceId,
+ handleCapChange,
+ playerCardGWs,
+ fixtures,
+ activeGW,
+ setSelectedPlayer,
+ handleUndoTransfer,
+ highlightTransferIds,
+ solverTransferPairs,
+ resetHighlightedTransfer,
+ chipsByGw,
+}) => {
+ return (
+
+ {/* PITCH LINES */}
+
+
+ {/* STARTERS */}
+
+
+ {["G", "D", "M", "F"].map((pos) => {
+ const rowPlayers = teamData.slice(0, 11).filter((p) => p.Pos === pos);
+ if (rowPlayers.length === 0) return null;
+ return (
+
+ {rowPlayers.map((p) => (
+ setSelectedPlayer(player)}
+ onUndo={handleUndoTransfer}
+ isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
+ onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
+ activeChipType={chipsByGw[activeGW]}
+ />
+ ))}
+
+ );
+ })}
+
+
+
+ {/* BENCH */}
+
+
+ {teamData.slice(11, 15).map((p, benchIndex) => (
+ setSelectedPlayer(player)}
+ onUndo={handleUndoTransfer}
+ isHighlighted={Array.from(highlightTransferIds[activeGW] || []).includes(p.ID)}
+ onSolverUndo={(solverTransferPairs[activeGW] || {})[p.ID] ? () => resetHighlightedTransfer(p) : undefined}
+ activeChipType={chipsByGw[activeGW]}
+ />
+ ))}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/PlayerCardVisual.jsx b/frontend/src/components/PlayerCardVisual.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..89bd4f4417603b9bfd76db0af5e60ef0e2cb233c
--- /dev/null
+++ b/frontend/src/components/PlayerCardVisual.jsx
@@ -0,0 +1,258 @@
+import React, { useContext } from "react";
+import { Plus, RotateCcw } from "lucide-react";
+import { getShortName } from "../utils/teams";
+import { getPlayerPrice } from "../utils/fplLogic";
+import { PlayerContext } from "../PlayerContext";
+
+export const PlayerCardVisual = ({
+ player,
+ isBench,
+ captainId,
+ viceId,
+ handleCapChange,
+ playerCardGWs,
+ fixtures,
+ activeGW,
+ onPlayerClick,
+ onUndo,
+ onSolverUndo,
+ activeChipType,
+}) => {
+ if (player.isBlank) {
+ return (
+
+ {player.replacedPlayer && (
+
+ e.stopPropagation()}
+ onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
+ 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"
+ title="Undo Transfer"
+ >
+
+
+
+ )}
+
+
+ {player.Pos}
+
+
+ );
+ }
+
+ const isCap = player.ID === captainId;
+ const isVice = player.ID === viceId;
+ const photoUrl = player.photo
+ ? `https://resources.premierleague.com/premierleague25/photos/players/110x140/${player.photo.replace(".jpg", ".png")}`
+ : "";
+
+ const { effectiveFixtures } = useContext(PlayerContext) || {};
+
+ const TEAM_MAP = {
+ "Arsenal": 1, "Aston Villa": 2, "Burnley": 3, "Bournemouth": 4, "AFC Bournemouth": 4, "Brentford": 5,
+ "Brighton": 6, "Brighton and Hove Albion": 6, "Chelsea": 7, "Crystal Palace": 8, "Everton": 9, "Fulham": 10,
+ "Leeds": 11, "Leeds United": 11, "Liverpool": 12, "Man City": 13, "Manchester City": 13,
+ "Man Utd": 14, "Manchester United": 14, "Newcastle": 15, "Newcastle United": 15,
+ "Nott'm Forest": 16, "Nottingham Forest": 16, "Sunderland": 17,
+ "Spurs": 18, "Tottenham": 18, "Tottenham Hotspur": 18,
+ "West Ham": 19, "West Ham United": 19, "Wolves": 20, "Wolverhampton Wanderers": 20
+ };
+
+ const getActiveMatches = (teamName, gw) => {
+ if (!fixtures || !fixtures.length || !gw) return [];
+ const activeMatches = [];
+ fixtures.forEach(m => {
+ if (m.home_team !== teamName && m.away_team !== teamName) return;
+
+ const hId = m.home_team_id || TEAM_MAP[m.home_team] || m.home_team;
+ const aId = m.away_team_id || TEAM_MAP[m.away_team] || m.away_team;
+ const matchId = `${hId}_vs_${aId}`;
+
+ const override = effectiveFixtures?.[matchId];
+
+ if (override) {
+ if (Number(override[gw]) >= 0.01) activeMatches.push({ ...m, prob: Number(override[gw]) });
+ } else if (String(m.GW) === String(gw)) {
+ activeMatches.push({ ...m, prob: 1.0 });
+ }
+ });
+ return activeMatches;
+ };
+
+ const currentGwMatches = getActiveMatches(player.Team, activeGW);
+ const isBlankThisGw = currentGwMatches.length === 0;
+
+ const renderFixtures = (teamName, gw) => {
+ const activeMatches = gw === activeGW ? currentGwMatches : getActiveMatches(teamName, gw);
+
+ if (activeMatches.length === 0) {
+ return BLANK ;
+ }
+
+ return activeMatches.map((m, idx) => {
+ const isHome = m.home_team === teamName;
+ const oppName = getShortName(isHome ? m.away_team : m.home_team);
+ const loc = isHome ? "H" : "A";
+ const isGhost = m.prob < 1;
+
+ return (
+
+
+ {/* Team Name - Scaled down 1px and tightened tracking */}
+
+ {oppName}
+
+
+ {/* Location - Scaled down 1px */}
+
+ ({loc})
+
+
+ {/* Percentage Pill - Scaled down, tighter internal padding */}
+ {isGhost && (
+
+ {Math.round(m.prob * 100)}%
+
+ )}
+
+
+ {/* Divider - Margins shrunk from mx-[5px] to mx-[3px] */}
+ {idx < activeMatches.length - 1 && (
+ •
+ )}
+
+ );
+ });
+ };
+
+ const evStyles = [
+ "text-emerald-400 text-[15px] sm:text-base font-extrabold",
+ "text-emerald-500 text-[12px] sm:text-[13px] font-bold",
+ "text-emerald-600 text-[10px] sm:text-[11px] font-semibold",
+ ];
+
+ return (
+
+
+ {!isBench && handleCapChange && (
+ <>
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleCapChange(player.ID, "C");
+ }}
+ className={`w-6 h-6 flex items-center justify-center rounded-full text-[11px] font-bold transition-colors shadow-lg ${isCap
+ ? activeChipType === "tc"
+ ? "bg-purple-500 text-white border border-purple-300 shadow-[0_0_10px_rgba(168,85,247,0.7)] text-[9px]"
+ : "bg-yellow-400 text-slate-900 border border-white"
+ : "bg-slate-900/90 text-slate-400 border border-slate-700 hover:text-yellow-400"
+ }`}
+ >
+ {isCap && activeChipType === "tc" ? "TC" : "C"}
+
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ handleCapChange(player.ID, "V");
+ }}
+ 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"}`}
+ >
+ V
+
+ >
+ )}
+ {onSolverUndo && (
+ e.stopPropagation()}
+ onClick={(e) => {
+ e.stopPropagation();
+ onSolverUndo(player);
+ }}
+ 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"
+ title="Revert solver transfer"
+ >
+
+
+ )}
+ {player.replacedPlayer && (
+ e.stopPropagation()}
+ onClick={(e) => onUndo(e, player.ID, player.replacedPlayer)}
+ 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"
+ title="Undo transfer"
+ >
+
+
+ )}
+
+
+
+ {playerCardGWs.map((gw, i) => (
+
+ {Number(player[`${gw}_Pts`] || 0).toFixed(2)}
+
+ ))}
+
+
+ {photoUrl ? (
+
+ ) : (
+
+ )}
+
+
+
+ {player.Name}
+
+
+
+ {isBlankThisGw ? "-" : (player[`${activeGW}_xMins`] ?? 90)}{" "}
+
+ xMins
+
+
+ |
+
+ £{getPlayerPrice(player).toFixed(1)}
+
+
+
+ {/* THE FIX: Standard w-full with justify-center fixes the cutoff bug */}
+
+ {renderFixtures(player.Team, activeGW)}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/PlayerModals.jsx b/frontend/src/components/PlayerModals.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..f189a5933d8969281f32ed1a169247af7c1cc0bc
--- /dev/null
+++ b/frontend/src/components/PlayerModals.jsx
@@ -0,0 +1,286 @@
+// src/components/PlayerModals.jsx
+import React,{ useState, useEffect, useContext } from "react";
+import { Search, Plus } from "lucide-react";
+import { getPlayerPrice } from "../utils/fplLogic";
+import { getShortName } from "../utils/teams";
+import { PlayerContext } from "../PlayerContext";
+
+const SafeMinsInput = ({ initialValue, onSave, isChild = false, disabled = false }) => {
+ const [val, setVal] = useState(initialValue);
+ useEffect(() => setVal(initialValue), [initialValue]);
+
+ return (
+ {
+ setVal(e.target.value);
+ onSave(e.target.value);
+ }}
+ className={isChild
+ ? "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"
+ : `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'}`
+ }
+ />
+ );
+};
+
+export const PlayerEditModal = ({
+ selectedPlayer,
+ setSelectedPlayer,
+ activeGW,
+ horizonGWs,
+ updatePlayerStat,
+ handleTransferOut,
+ fixtures,
+ fixtureOverrides,
+ sessionEdits,
+ globalPlayers
+}) => {
+
+ const { effectiveFixtures, globalXmins } = useContext(PlayerContext);
+ // THE FIX: Grab the live updating player, not the frozen snapshot!
+ const livePlayer = globalPlayers?.find(p => p.ID === selectedPlayer.ID) || selectedPlayer;
+
+ const TEAM_SHORTS = {
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
+ };
+
+ return (
+
+
+
+
+
{livePlayer.Name}
+
+ {livePlayer.Team}
+ |
+ £{getPlayerPrice(livePlayer).toFixed(1)}m
+
+
+
setSelectedPlayer(null)} className="text-slate-500 hover:text-white transition-colors bg-slate-900 p-2 rounded-full border border-slate-800">✕
+
+
+
+ {[
+ { label: `GW${activeGW} xG`, val: livePlayer[`${activeGW}_xG`] ?? livePlayer.xG ?? "-" },
+ { label: `GW${activeGW} xA`, val: livePlayer[`${activeGW}_xA`] ?? livePlayer.xA ?? "-" },
+ { label: `GW${activeGW} CS%`, val: livePlayer[`${activeGW}_CS_Pct`] ?? livePlayer.CS_Pct ?? "-" },
+ ]
+ .filter(stat => !(stat.label.includes('CS%') && livePlayer.Pos === 'F'))
+ .map((stat) => (
+
+ {stat.label}
+ {typeof stat.val === "number" ? stat.val.toFixed(2) : stat.val}
+
+ ))}
+
+
+
+
+
+
+ GW
+ Fixture
+ xMins
+ Proj. EV
+
+
+
+ {horizonGWs.map((gw) => {
+ const matches = [];
+ if (livePlayer.match_projections) {
+ Object.entries(livePlayer.match_projections).forEach(([mId, mData]) => {
+ // THE FIX 2: Look at the merged globals instead of the empty prop!
+ const override = effectiveFixtures?.[mId];
+
+ // THE FIX 3: Force Number() to prevent API float bugs
+ if (override && Number(override[gw]) > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) });
+ else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 });
+ });
+ }
+
+ const hasMultiple = matches.length > 1;
+ const isBlank = matches.length === 0;
+
+ return (
+
+
+ GW{gw}
+
+ {isBlank ? (
+ "BLANK"
+ ) : hasMultiple ? (
+ "MULTIPLE"
+ ) : (
+
+
+ {matches[0]?.is_home ? `${TEAM_SHORTS[matches[0].opponent_team_id]} (H)` : `${TEAM_SHORTS[matches[0]?.opponent_team_id]} (A)`}
+
+ {matches[0]?.prob < 1.0 && (
+
+ {Math.round(matches[0].prob * 100)}%
+
+ )}
+
+ )}
+
+
+
+ {
+ if (hasMultiple) {
+ matches.forEach(m => updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal));
+ } else {
+ updatePlayerStat(livePlayer.ID, gw, "xMins", newVal);
+ }
+ }}
+ />
+
+
+
+ {Number(livePlayer[`${gw}_Pts`] || 0).toFixed(2)}
+
+
+
+ {hasMultiple && matches.map(m => {
+ const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id;
+ const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`;
+ const globalMatchMins = globalXmins?.[livePlayer.ID]?.[m.id];
+ const sessionVal = sessionEdits?.[livePlayer.ID]?.[`${m.id}_xMins`];
+
+ const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins));
+ const scaledEV = (currentMins > 0 && m.xMins > 0) ? (m.Pts / m.xMins) * currentMins : 0;
+
+ return (
+
+ ↳
+
+ {fixLabel} ({Math.round(m.prob * 100)}%)
+
+
+ updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal)}
+ />
+
+
+ {(scaledEV * m.prob).toFixed(2)}
+
+
+ );
+ })}
+
+ );
+ })}
+
+
+
+
+ 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
+ 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
+
+
+
+
+ );
+};
+
+export const PlayerSearchModal = ({
+ selectedPlayer,
+ setSelectedPlayer,
+ searchQuery,
+ setSearchQuery,
+ sortConfig,
+ setSortConfig,
+ globalPlayers,
+ ownedPlayerIds,
+ activeGW,
+ itb,
+ handleAddPlayer,
+}) => {
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ className="flex-1 bg-transparent border-none outline-none text-slate-200 font-bold"
+ autoFocus
+ />
+ setSelectedPlayer(null)} className="text-slate-500 hover:text-white font-bold text-sm">Cancel
+
+
+
+ SORT BY:
+ setSortConfig({ key: "ev", direction: sortConfig.key === "ev" && sortConfig.direction === "desc" ? "asc" : "desc" })}
+ 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"}`}
+ >
+ Proj. EV {sortConfig.key === "ev" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""}
+
+ setSortConfig({ key: "price", direction: sortConfig.key === "price" && sortConfig.direction === "desc" ? "asc" : "desc" })}
+ 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"}`}
+ >
+ Price {sortConfig.key === "price" ? (sortConfig.direction === "desc" ? "↓" : "↑") : ""}
+
+
+
+
+ {globalPlayers
+ // THE FIX: Removed the restrictive 'replacedPlayer' ban and added defensive FPL ID type-checking
+ .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()))
+ .sort((a, b) => {
+ let valA = sortConfig.key === "ev" ? Number(a[`${activeGW}_Pts`] || 0) : getPlayerPrice(a);
+ let valB = sortConfig.key === "ev" ? Number(b[`${activeGW}_Pts`] || 0) : getPlayerPrice(b);
+ if (valA < valB) return sortConfig.direction === "desc" ? 1 : -1;
+ if (valA > valB) return sortConfig.direction === "desc" ? -1 : 1;
+ return 0;
+ })
+ .slice(0, 50)
+ .map((p) => {
+ // THE FIX: Your true FPL purchasing power includes the money freed up by selling the outgoing player
+ const sellingPrice = getPlayerPrice(selectedPlayer) || 0;
+ const maxBudget = itb + sellingPrice;
+ const cost = getPlayerPrice(p);
+ const isAffordable = cost <= maxBudget;
+
+ return (
+
handleAddPlayer(p)}
+ 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"}`}
+ >
+
+ {p.Name}
+ {p.Team} • {p.Pos}
+
+
+
+ EV: {Number(p[`${activeGW}_Pts`] || 0).toFixed(2)}
+ {p[`${activeGW}_xMins`] || 0} xMins
+
+
£{cost.toFixed(1)}m
+
+
+
+ );
+ })}
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/ProjectionsTable.jsx b/frontend/src/components/ProjectionsTable.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..a9a990fe388edfa18f89a54c8bc124aa1e92c54d
--- /dev/null
+++ b/frontend/src/components/ProjectionsTable.jsx
@@ -0,0 +1,664 @@
+import React, { useState, useEffect, useMemo, useRef, useContext } from 'react';
+import { Search, ChevronLeft, ChevronRight, Shield, Download, RotateCcw, Loader2 } from 'lucide-react';
+import { getShortName } from '../utils/teams';
+import { PlayerContext } from '../PlayerContext';
+
+// --- BASELINE INPUT WITH LIVE AUTO-SAVE ---
+// --- BASELINE INPUT WITH LIVE AUTO-SAVE & SNAP-PROOF MEMORY ---
+const BaselineInput = ({ player, handleUpdate }) => {
+ const [val, setVal] = useState(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
+
+ useEffect(() => {
+ setVal(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : '');
+ }, [player.baseline_xMins]);
+
+ return (
+ setVal(e.target.value)}
+ onBlur={() => {
+ let num = val === '' ? 0 : parseInt(val, 10);
+ num = Math.max(0, Math.min(90, num)); // CAP FIX: Locks between 0 and 90
+ setVal(num);
+ handleUpdate(player.ID, 'baseline', null, num);
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
+ 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"
+ />
+ );
+};
+
+// --- DGW CHILD INPUT WITH LIVE AUTO-SAVE ---
+const SafeChildInput = ({ initialValue, onSave }) => {
+ const [val, setVal] = useState(initialValue);
+ useEffect(() => setVal(initialValue), [initialValue]);
+ return (
+ setVal(e.target.value)}
+ onBlur={() => {
+ let num = val === '' ? 0 : parseFloat(val);
+ num = Math.max(0, Math.min(90, num)); // CAP FIX
+ setVal(num);
+ onSave(num);
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
+ 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"
+ />
+ );
+};
+
+// --- GW INPUT WITH LIVE AUTO-SAVE & DGW POPOVER ---
+// --- GW INPUT WITH SAFE FRONTEND SAVING ---
+// --- GW INPUT WITH SAFE FRONTEND SAVING ---
+const GwMinsInput = ({ player, gw, handleUpdate }) => {
+ const {effectiveFixtures, sessionEdits, globalXmins } = useContext(PlayerContext);
+ const [showPopover, setShowPopover] = useState(false);
+ const [isFocused, setIsFocused] = useState(false);
+ const popoverRef = useRef(null);
+
+ const [val, setVal] = useState(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
+
+ useEffect(() => {
+ setVal(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : '');
+ }, [player[`${gw}_xMins`]]);
+
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (popoverRef.current && !popoverRef.current.contains(event.target)) setShowPopover(false);
+ };
+ if (showPopover) document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [showPopover]);
+
+ const matches = [];
+ if (player.match_projections) {
+ Object.entries(player.match_projections).forEach(([mId, mData]) => {
+ const override = effectiveFixtures?.[mId];
+ // THE FIX: Force Number() conversion so strings like "1" don't break the math!
+ if (override && override[gw] > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) });
+ else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 });
+ });
+ }
+
+ const hasMultiple = matches.length > 1 || (matches.length === 1 && Math.abs(matches[0].prob - 1.0) > 0.001);
+ const isBlank = matches.length === 0;
+
+ const TEAM_SHORTS = {
+ 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE",
+ 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL",
+ 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW",
+ 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL"
+ };
+
+ if (isBlank) return - ;
+ const hoverFixtureText = matches.map(m => `${TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id} ${m.is_home ? '(H)' : '(A)'}`).join(" & ");
+
+ const handleParentSave = (newVal) => {
+ let numVal = newVal === '' ? 0 : parseFloat(newVal);
+ numVal = Math.max(0, Math.min(90, numVal)); // CAP FIX
+ const currentAvg = Math.round(player[`${gw}_xMins`] || 0);
+ if (numVal === currentAvg) {
+ setVal(numVal);
+ return;
+ }
+ setVal(numVal); // Snaps the UI instantly
+
+ if (hasMultiple) {
+ const edits = {};
+ matches.forEach(m => { edits[m.id] = numVal; });
+ handleUpdate(player.ID, 'batch', edits, null);
+ } else {
+ handleUpdate(player.ID, 'single', gw, numVal);
+ }
+ };
+
+ return (
+
+ {isFocused && !hasMultiple && !isBlank && (
+
+ {hoverFixtureText}
+
+ )}
+
hasMultiple && setShowPopover(true)}
+ onFocus={() => !hasMultiple && setIsFocused(true)}
+ onChange={(e) => !hasMultiple && setVal(e.target.value)}
+ onBlur={(e) => {
+ if (!hasMultiple) {
+ setIsFocused(false); // Hides tooltip when you click away
+ handleParentSave(e.target.value);
+ }
+ }}
+ onKeyDown={(e) => e.key === 'Enter' && e.target.blur()}
+ 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`}
+ />
+
+ {showPopover && hasMultiple && (
+
+
+ Edit Match Splits
+ { e.stopPropagation(); setShowPopover(false); }} className="text-indigo-400 hover:text-white text-xs">✕
+
+
+ {matches.map(m => {
+ const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id || "OPP";
+ const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`;
+ const globalMatchMins = globalXmins?.[player.ID]?.[m.id];
+ const sessionVal = sessionEdits?.[player.ID]?.[`${m.id}_xMins`];
+ const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins));
+
+ return (
+
+ {fixLabel} ({Math.round(m.prob*100)}%)
+ handleUpdate(player.ID, 'single', m.id, newVal)}
+ />
+
+ );
+ })}
+
+
+ )}
+
+ );
+};
+export default function ProjectionsTable() {
+ const {
+ globalPlayers: players, setGlobalPlayers, isLoadingDB,
+ projSearchTerm: searchTerm, setProjSearchTerm: setSearchTerm,
+ sessionEdits, setSessionEdits, manualOverrides, effectiveFixtures,setOriginalPlayers,globalXmins
+ } = useContext(PlayerContext);
+
+ const [sortConfig, setSortConfig] = useState({ key: 'Total Points', direction: 'desc' });
+ const [currentPage, setCurrentPage] = useState(1);
+ const itemsPerPage = 50;
+
+ const tableContainerRef = useRef(null);
+ useEffect(() => {
+ if (tableContainerRef.current) {
+ tableContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' });
+ }
+ }, [currentPage]);
+
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [adminPassword, setAdminPassword] = useState('');
+ const [showAdminLogin, setShowAdminLogin] = useState(false);
+ const [clickCount, setClickCount] = useState(0);
+ const clickTimeoutRef = useRef(null);
+
+ const handleSecretClick = () => {
+ setClickCount((prev) => {
+ const newCount = prev + 1;
+ if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; }
+ return newCount;
+ });
+ if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current);
+ clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000);
+ };
+
+ const gameweeks = useMemo(() => {
+ if (!players || players.length === 0) return [];
+ const gwSet = new Set();
+ Object.keys(players[0]).forEach(k => {
+ if (/^\d+_Pts$/.test(k)) {
+ const num = parseInt(k.split('_')[0], 10);
+ if (num >= 1 && num <= 38) gwSet.add(num);
+ }
+ });
+ return Array.from(gwSet).sort((a, b) => a - b);
+ }, [players]);
+
+ const getDynamicTotal = (p) => gameweeks.reduce((sum, gw) => sum + (Number(p[`${gw}_Pts`]) || 0), 0);
+ const getDynamicAvg = (p) => gameweeks.length > 0 ? getDynamicTotal(p) / gameweeks.length : 0;
+
+ const handleUpdate = async (playerId, type, gw, valueStr) => {
+ const value = type === 'baseline' ? parseInt(valueStr, 10) || 0 : parseFloat(valueStr) || 0;
+
+ // 1. Grab the active baseline from memory so Python doesn't forget it!
+ const activeBaseline = sessionEdits[playerId]?.baseline_xMins;
+
+ // 2. Prevent Python Crash: Separate Match IDs (13_vs_1) from real Gameweeks (34)
+ const realGwEdits = {};
+ if (type === 'batch') {
+ Object.keys(gw).forEach(k => { realGwEdits[k] = gw[k]; });
+ } else if (type === 'single') {
+ realGwEdits[gw] = value;
+ }
+
+ // 3. Update local React memory instantly (handles DGW splits perfectly)
+ setSessionEdits(prev => {
+ const next = { ...prev };
+ if (!next[playerId]) next[playerId] = {};
+
+ if (type === 'baseline') {
+ next[playerId]['baseline_xMins'] = value;
+ } else if (type === 'batch') {
+ Object.keys(gw).forEach(k => { next[playerId][`${k}_xMins`] = gw[k]; });
+ } else {
+ next[playerId][`${gw}_xMins`] = value;
+ }
+
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ saved_edits: { ...next, _solver_overrides: manualOverrides } })
+ });
+ }
+ return next;
+ });
+
+ // 4. If this is a DGW match split ('13_vs_1'), STOP HERE. Don't crash Python!
+
+ try {
+ const payload = { player_id: playerId, is_admin: isAdmin, admin_password: adminPassword, gw_edits: realGwEdits };
+
+ // 5. Prevent Reset Bug: ALWAYS send the baseline to Python!
+ if (type === 'baseline') {
+ payload.baseline_edit = value;
+ } else if (activeBaseline !== undefined) {
+ payload.baseline_edit = activeBaseline;
+ }
+
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/player/update', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
+ });
+ if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend recalculation failed'); }
+
+ const updatedRow = await res.json();
+
+ // 6. Merge Python's exact decayed math into the table
+ if (setGlobalPlayers) {
+ setGlobalPlayers(prev => prev.map(p => {
+ const newBaseline = type === 'baseline' ? (valueStr === '' ? null : value) : (activeBaseline !== undefined ? activeBaseline : p.baseline_xMins);
+ if (p.ID === playerId) return { ...p, ...updatedRow, baseline_xMins: newBaseline };
+ return p;
+ }));
+ }
+
+ // 7. Lock Python's curve into memory (from your old working file)
+ if (type === 'baseline') {
+ setSessionEdits(prev => {
+ const next = { ...prev };
+ gameweeks.forEach(g => {
+ next[playerId][`${g}_xMins`] = updatedRow[`${g}_xMins`];
+ next[playerId][`${g}_Pts`] = updatedRow[`${g}_Pts`];
+ });
+ return next;
+ });
+ }
+
+ } catch (err) { console.error("Recalculation error:", err); }
+ };
+
+ const resetPlayer = async (playerId) => {
+ try {
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
+ const freshData = await res.json();
+ const cleanPlayer = freshData.find(p => p.ID === playerId);
+ if (cleanPlayer && setGlobalPlayers) {
+
+ // THE FLICKER FIX: Apply the UI math interceptor instantly before merging the reset player!
+ if (cleanPlayer.match_projections) {
+ gameweeks.forEach(g => {
+ cleanPlayer[`${g}_Pts`] = 0;
+ cleanPlayer[`${g}_xMins`] = 0;
+ cleanPlayer[`${g}_probSum`] = 0;
+ });
+ Object.entries(cleanPlayer.match_projections).forEach(([mId, mData]) => {
+ const pts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
+ const mins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
+ const override = effectiveFixtures?.[mId];
+ if (override) {
+ Object.entries(override).forEach(([gwStr, prob]) => {
+ if (prob > 0) {
+ cleanPlayer[`${gwStr}_Pts`] = (cleanPlayer[`${gwStr}_Pts`] || 0) + (pts * prob);
+ cleanPlayer[`${gwStr}_xMins`] = (cleanPlayer[`${gwStr}_xMins`] || 0) + (mins * prob);
+ cleanPlayer[`${gwStr}_probSum`] = (cleanPlayer[`${gwStr}_probSum`] || 0) + prob;
+ }
+ });
+ } else {
+ const defGw = mData.default_gw;
+ if (defGw) {
+ cleanPlayer[`${defGw}_Pts`] = (cleanPlayer[`${defGw}_Pts`] || 0) + pts;
+ cleanPlayer[`${defGw}_xMins`] = (cleanPlayer[`${defGw}_xMins`] || 0) + mins;
+ cleanPlayer[`${defGw}_probSum`] = (cleanPlayer[`${defGw}_probSum`] || 0) + 1.0;
+ }
+ }
+ });
+ gameweeks.forEach(g => {
+ if (cleanPlayer[`${g}_probSum`] > 0) {
+ cleanPlayer[`${g}_xMins`] = cleanPlayer[`${g}_xMins`] / cleanPlayer[`${g}_probSum`];
+ }
+ });
+ }
+
+ setGlobalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
+ if (setOriginalPlayers) {
+ setOriginalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p));
+ }
+ setSessionEdits(prev => {
+ const newEdits = { ...prev };
+ delete newEdits[playerId];
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverrides } })
+ });
+ }
+ return newEdits;
+ });
+ }
+ } catch (e) { console.error("Failed to reset player", e); }
+ };
+
+ const resetAll = async () => {
+ try {
+ const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections');
+ const freshData = await res.json();
+ if (setGlobalPlayers) setGlobalPlayers(freshData);
+ setSessionEdits(prev => {
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ saved_edits: { _solver_overrides: manualOverrides } })
+ });
+ }
+ return {};
+ });
+ } catch (e) { console.error("Failed to reset all", e); }
+ };
+
+ const downloadCSV = () => {
+ if (!players || players.length === 0) return;
+ const headers = ["Pos", "ID", "Name", "BV", "SV", "Team"];
+ gameweeks.forEach(gw => {
+ headers.push(`${gw}_xMins`, `${gw}_Pts`);
+ });
+ headers.push("Total Points", "Average Points");
+
+ let csvContent = "data:text/csv;charset=utf-8,";
+ csvContent += headers.join(",") + "\n";
+
+ const escapeCsv = (str) => {
+ if (str == null) return "";
+ const s = String(str);
+ return s.includes(",") ? `"${s}"` : s;
+ };
+
+ sortedAndFilteredData.forEach(p => {
+ const row = [
+ p.Pos,
+ p.ID,
+ escapeCsv(p.Name),
+ p.BV,
+ p.SV !== undefined ? p.SV : p.BV,
+ escapeCsv(p.Team)
+ ];
+ gameweeks.forEach(gw => {
+ const mins = Number(p[`${gw}_xMins`]) || 0;
+ const pts = Number(p[`${gw}_Pts`]) || 0;
+ row.push(Math.round(mins));
+ row.push(pts.toFixed(2));
+ });
+ row.push(getDynamicTotal(p).toFixed(2));
+ row.push(getDynamicAvg(p).toFixed(2));
+ csvContent += row.join(",") + "\n";
+ });
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", `luigis_mansion.csv`);
+
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ const handleSort = (key) => {
+ let direction = 'desc';
+ if (sortConfig.key === key && sortConfig.direction === 'desc') direction = 'asc';
+ setSortConfig({ key, direction });
+ };
+
+ const sortedAndFilteredData = useMemo(() => {
+ if (!players) return [];
+
+ // Add the special character normalizer
+ const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : "";
+ const cleanSearch = cleanString(searchTerm);
+
+ let filtered = searchTerm ? players.filter(p => cleanString(p.Name).includes(cleanSearch)) : [...players];
+
+ return filtered.sort((a, b) => {
+ let valA = sortConfig.key === 'Total Points' ? getDynamicTotal(a) : (sortConfig.key === 'Average Points' ? getDynamicAvg(a) : a[sortConfig.key]);
+ let valB = sortConfig.key === 'Total Points' ? getDynamicTotal(b) : (sortConfig.key === 'Average Points' ? getDynamicAvg(b) : b[sortConfig.key]);
+ if (sortConfig.key === 'Team') { valA = getShortName(valA); valB = getShortName(valB); }
+ if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1;
+ if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1;
+ return 0;
+ });
+ }, [players, sortConfig, searchTerm, gameweeks]);
+
+ useEffect(() => setCurrentPage(1), [searchTerm, sortConfig]);
+
+ const totalPages = Math.ceil(sortedAndFilteredData.length / itemsPerPage);
+ const paginatedData = sortedAndFilteredData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage);
+
+ const getMinsColor = (mins) => `rgba(52, 211, 153, ${Math.min(mins / 90, 1) * 0.4})`;
+ const getPtsColor = (pts) => pts <= 0 ? 'transparent' : `rgba(16, 185, 129, ${Math.min(pts / 10, 1) * 0.6})`;
+
+ if (isLoadingDB) return
;
+
+ const displayedPlayers = useMemo(() => {
+ return paginatedData.map(p => {
+ if (!p.match_projections) return p;
+ const cloned = { ...p };
+
+ gameweeks.forEach(g => {
+ cloned[`${g}_Pts`] = 0;
+ cloned[`${g}_xMins`] = 0;
+ cloned[`${g}_probSum`] = 0;
+ });
+
+ const manualBaseline = sessionEdits[p.ID]?.baseline_xMins;
+
+ Object.entries(p.match_projections).forEach(([mId, mData]) => {
+ const override = effectiveFixtures?.[mId];
+
+ let manualMins = sessionEdits[p.ID]?.[`${mId}_xMins`];
+ const globalMatchMins = globalXmins?.[p.ID]?.[mId];
+ if (manualMins === undefined) {
+ if (globalMatchMins !== undefined) {
+ manualMins = globalMatchMins;
+ } else {
+ let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw;
+ if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw];
+ }
+ }
+
+ // Safely get the unedited minutes from the backend
+ const origMins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0);
+ let activeMins = origMins;
+
+ // THE DECAY FIX: Use a ratio to preserve the backend curve instead of flattening it
+ if (manualMins !== undefined) {
+ activeMins = Number(manualMins);
+ } else if (manualBaseline !== undefined) {
+ const origBase = p.baseline_xMins || 90;
+ const ratio = origBase > 0 ? (Number(manualBaseline) / origBase) : 1.0;
+ activeMins = Math.min((origMins * ratio), 90);
+ }
+
+ const scaling = (activeMins > 0 && origMins > 0) ? (activeMins / origMins) : (activeMins === 0 ? 0 : 1);
+ const basePts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0);
+ const aPts = basePts * scaling;
+
+ if (override) {
+ Object.entries(override).forEach(([gwStr, prob]) => {
+ if (prob > 0) {
+ cloned[`${gwStr}_Pts`] = (cloned[`${gwStr}_Pts`] || 0) + (aPts * prob);
+ cloned[`${gwStr}_xMins`] = (cloned[`${gwStr}_xMins`] || 0) + (activeMins * prob);
+ cloned[`${gwStr}_probSum`] = (cloned[`${gwStr}_probSum`] || 0) + prob;
+ }
+ });
+ } else {
+ const defGw = mData.default_gw;
+ if (defGw) {
+ cloned[`${defGw}_Pts`] = (cloned[`${defGw}_Pts`] || 0) + aPts;
+ cloned[`${defGw}_xMins`] = (cloned[`${defGw}_xMins`] || 0) + activeMins;
+ cloned[`${defGw}_probSum`] = (cloned[`${defGw}_probSum`] || 0) + 1.0;
+ }
+ }
+ });
+
+ gameweeks.forEach(g => {
+ if (cloned[`${g}_probSum`] > 0) {
+ cloned[`${g}_xMins`] = cloned[`${g}_xMins`] / cloned[`${g}_probSum`];
+ }
+ });
+
+ return cloned;
+ });
+ }, [paginatedData, sessionEdits, effectiveFixtures, gameweeks]);
+
+ return (
+
+
+
+
+
+
+
+
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" />
+ {isAdmin &&
}
+
+ {showAdminLogin && !isAdmin && (
+
+ 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" />
+ setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-sm text-white transition-colors">Login
+
+ )}
+
+
+ {Object.keys(sessionEdits).length > 0 && (
+ Reset to Default
+ )}
+ Export CSV
+
+
+
+
+
+
+
+ handleSort('Pos')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Pos
+ handleSort('Name')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Name
+ handleSort('Team')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Team
+ handleSort('BV')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Cost
+ handleSort('baseline_xMins')} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-900/50 cursor-pointer">
+
+
+ {gameweeks.map(gw => (
+
+
+
GW{gw}
+
+ handleSort(`${gw}_xMins`)}>xMins
+ handleSort(`${gw}_Pts`)}>xPts
+
+
+
+ ))}
+ 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
+ Reset
+
+
+
+
+ {displayedPlayers.map((player) => (
+
+ {player.Pos}
+ {player.Name}
+ {getShortName(player.Team)}
+ {player.BV}
+
+
+
+
+
+
+
+ {gameweeks.map(gw => (
+
+
+
+
+
+ {/* TAG FIX: Tiny, padded, and pointer-events-none so it never blocks clicks */}
+ {player[`${gw}_probSum`] > 1.01 && (
+
+ DGW
+
+ )}
+ {player[`${gw}_probSum`] < 0.99 && player[`${gw}_probSum`] > 0.01 && (
+
+ %
+
+ )}
+
+
+ {Number(player[`${gw}_Pts`]).toFixed(2)}
+
+
+
+ ))}
+
+ {getDynamicTotal(player).toFixed(2)}
+
+ {sessionEdits[player.ID] && (
+ resetPlayer(player.ID)} className="p-1 text-slate-500 hover:text-red-400 transition-colors" title="Reset Player">
+
+
+ )}
+
+
+ ))}
+
+
+
+ {totalPages > 1 && (
+
+
+ Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)} of {sortedAndFilteredData.length} players
+
+
+ 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">
+
+
+ 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">
+
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/Solver.jsx b/frontend/src/components/Solver.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..6c0a26118ef6ee50204485d93abdfbc5bc05f9b0
--- /dev/null
+++ b/frontend/src/components/Solver.jsx
@@ -0,0 +1,1806 @@
+import React, { useState, useEffect, useMemo, useContext, useRef } from "react";
+import { createPortal } from "react-dom";
+import { Search, Loader2, RotateCcw, Shield, Settings, Zap, Plus, Copy, Trash2 } from "lucide-react";
+import { DndContext, DragOverlay, closestCenter, PointerSensor, useSensor, useSensors } from "@dnd-kit/core";
+import { PlayerContext } from "../PlayerContext";
+import { CHIP_CONFIG, getPlayerPrice, normalizeBenchGkFirst } from "../utils/fplLogic";
+import { useFplSolverApi } from "../hooks/useFplSolverApi";
+import { SolverOutputPanel } from "./SolverOutputPanel";
+import { PitchView } from "./PitchView";
+import { PlayerEditModal, PlayerSearchModal } from "./PlayerModals";
+import { PlayerCardVisual } from "./PlayerCardVisual";
+import { TabsPanel } from "./TabsPanel";
+import { AdvancedSettingsModal, DEFAULT_SETTINGS } from "./AdvancedSettingsModal";
+import { ActiveMovesPanel } from "./ActiveMovesPanel";
+import { DraftsComparisonTable } from "./DraftsComparisonTable";
+
+export default function Solver() {
+ const {
+ 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
+ } = useContext(PlayerContext);
+
+ // --- THE PRISTINE VAULT ---
+ const pristineSquadRef = useRef({});
+
+ const [pendingAutoReset, setPendingAutoReset] = useState(false);
+ const lastOverridesRef = useRef(fixtureOverrides);
+
+ // Watches for fixture changes and queues an auto-reset for when the math finishes
+ useEffect(() => {
+ if (lastOverridesRef.current !== fixtureOverrides) {
+ lastOverridesRef.current = fixtureOverrides;
+ setPendingAutoReset(true);
+ }
+ }, [fixtureOverrides]);
+
+ // --- STRICT VAULT-BASED PLAYER FACTORY ---
+ const hydratePlayer = (id, knownPristineData = null) => {
+ const globalMatch = globalPlayers.find((p) => String(p.ID) === String(id));
+ if (!globalMatch) return null;
+
+ // 1. Trust explicit overrides (like when clicking 'undo transfer')
+ if (knownPristineData && typeof knownPristineData === "object" && knownPristineData.purchase_price !== undefined) {
+ const hydrated = { ...globalMatch, ...knownPristineData };
+ hydrated.now_cost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price;
+ for (const key in globalMatch) { if (key.includes("_Pts")) hydrated[key] = globalMatch[key]; }
+ hydrated.Price = hydrated.selling_price !== undefined ? hydrated.selling_price : getPlayerPrice(hydrated);
+ return hydrated;
+ }
+
+ const marketCost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price;
+ const lockedBaselinePlayer = pristineSquadRef.current[id];
+
+ // 2. CHECK THE CHAIN: Was this player sold in any previous gameweek?
+ let isChainBroken = false;
+
+ if (lockedBaselinePlayer && availableGWs && availableGWs.length > 0) {
+ const pastGWs = availableGWs.filter(g => g < activeGW).sort((a, b) => a - b);
+
+ for (const gw of pastGWs) {
+ if (chipsByGw[gw] === "fh") continue; // FH sells do not break the permanent chain
+
+ // Check human moves
+ const mLock = manualOverrides[gw];
+ if (mLock?.manualTransfers && Object.values(mLock.manualTransfers).some(p => String(p?.ID) === String(id))) {
+ isChainBroken = true; break;
+ }
+
+ // Check solver moves
+ const sPairs = solverTransferPairs[gw];
+ if (sPairs && Object.values(sPairs).some(pair => String(pair.outPlayer?.ID) === String(id))) {
+ isChainBroken = true; break;
+ }
+ }
+ } else {
+ // If they aren't in the vault, they were bought after GW1. The chain is inherently broken.
+ isChainBroken = true;
+ }
+
+ // 3. APPLY THE LOGIC
+ let finalPurchasePrice, finalSellingPrice;
+
+ if (lockedBaselinePlayer && !isChainBroken) {
+ // Chain unbroken: They are a GW1 original. Use the locked vault prices.
+ finalPurchasePrice = lockedBaselinePlayer.purchase_price;
+ finalSellingPrice = lockedBaselinePlayer.selling_price !== undefined ? lockedBaselinePlayer.selling_price : getPlayerPrice(lockedBaselinePlayer);
+ } else {
+ // Chain broken: They were bought later, or sold and repurchased. Price resets to market cost.
+ finalPurchasePrice = marketCost;
+ finalSellingPrice = marketCost;
+ }
+
+ const hydrated = {
+ ...globalMatch,
+ ...(lockedBaselinePlayer && !isChainBroken ? lockedBaselinePlayer : {}),
+ purchase_price: finalPurchasePrice,
+ selling_price: finalSellingPrice,
+ Price: finalSellingPrice, // Lock the display value
+ now_cost: marketCost
+ };
+
+ // Overlay freshest points
+ for (const key in globalMatch) {
+ if (key.includes("_Pts")) hydrated[key] = globalMatch[key];
+ }
+
+ return hydrated;
+ };
+
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [fixtures, setFixtures] = useState([]);
+ const [activeDragPlayer, setActiveDragPlayer] = useState(null);
+ const [selectedPlayer, setSelectedPlayer] = useState(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [sortConfig, setSortConfig] = useState({ key: "ev", direction: "desc" });
+ const [showIdPrompt, setShowIdPrompt] = useState(false);
+ // --- DEFAULT ID ONBOARDING STATE ---
+ const [showInitialIdPrompt, setShowInitialIdPrompt] = useState(false);
+ const [initialIdInput, setInitialIdInput] = useState("");
+
+ // Trigger popup if logged in but no default ID is set
+ useEffect(() => {
+ if (isLoggedIn && userProfile && !userProfile.defaultTeamId) {
+ setShowInitialIdPrompt(true);
+ } else {
+ setShowInitialIdPrompt(false);
+ }
+ }, [isLoggedIn, userProfile]);
+
+ const handleSaveInitialId = () => {
+ const parsedId = parseInt(initialIdInput);
+ if (!parsedId) return;
+
+ const token = localStorage.getItem('fpl_token');
+ if (token) {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ default_team_id: parsedId })
+ });
+ setUserProfile(prev => ({ ...prev, defaultTeamId: parsedId }));
+ setTeamId(String(parsedId)); // Auto-load the ID for them
+ setShowInitialIdPrompt(false);
+ }
+ };
+ const [pendingTeamId, setPendingTeamId] = useState(null);
+ const [lastLoadedId, setLastLoadedId] = useState(teamData.length > 0 ? teamId : null);
+
+ const [solverTab, setSolverTab] = useState("solver");
+ const [showAdvancedSettings, setShowAdvancedSettings] = useState(false);
+ const [banSearch, setBanSearch] = useState("");
+ const [lockSearch, setLockSearch] = useState("");
+ const [chipSolveOptions, setChipSolveOptions] = useState({ wc: [], fh: [], bb: [], tc: [] });
+ const [showDraftMenu, setShowDraftMenu] = useState(false);
+
+ const [sensTimer, setSensTimer] = useState(0);
+ const [chipSolveTimer, setChipSolveTimer] = useState(0);
+
+ const abortControllerRef = useRef(null);
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
+
+ const {
+ isSolving, isChipSolving, isRunningSens, pendingSolutions, setPendingSolutions, chipSolveSolutions, setChipSolveSolutions, sensResults, setSensResults, sensViewGw, setSensViewGw, handleSolve: apiHandleSolve, handleChipSolve: apiHandleChipSolve, handleSensAnalysis: apiHandleSensAnalysis, loadSettingsFromCloud, saveSettingsToCloud
+ } = useFplSolverApi(abortControllerRef);
+
+ useEffect(() => {
+ let interval;
+ if (isRunningSens) { interval = setInterval(() => setSensTimer((t) => t + 1), 1000); } else { setSensTimer(0); }
+ return () => clearInterval(interval);
+ }, [isRunningSens]);
+
+ useEffect(() => {
+ let interval;
+ if (isChipSolving) { interval = setInterval(() => setChipSolveTimer((t) => t + 1), 1000); } else { setChipSolveTimer(0); }
+ return () => clearInterval(interval);
+ }, [isChipSolving]);
+
+ const maxAvailableHorizon = useMemo(() => (availableGWs.length ? Math.min(10, availableGWs.length) : 10), [availableGWs]);
+ const horizonGWs = useMemo(() => (availableGWs.length ? availableGWs.slice(0, horizon) : []), [availableGWs, horizon]);
+ const playerCardGWs = useMemo(() => {
+ if (!horizonGWs.length || !activeGW) return [];
+ const idx = horizonGWs.indexOf(activeGW);
+ return idx === -1 ? [] : horizonGWs.slice(idx).slice(0, 3);
+ }, [horizonGWs, activeGW]);
+ const solveGWs = useMemo(() => {
+ if (!horizonGWs.length || !activeGW) return horizonGWs;
+ const idx = horizonGWs.indexOf(activeGW);
+ return idx === -1 ? horizonGWs : horizonGWs.slice(idx);
+ }, [horizonGWs, activeGW]);
+
+ const solveGWLabel = useMemo(() => {
+ if (!solveGWs.length) return "";
+ return solveGWs.length === 1 ? `GW${solveGWs[0]}` : `GW${solveGWs[0]}–${solveGWs[solveGWs.length - 1]}`;
+ }, [solveGWs]);
+
+ const ownedPlayerIds = useMemo(() => new Set(teamData.filter((p) => !p.isBlank).map((p) => p.ID)), [teamData]);
+
+ const hitsThisGw = useMemo(() => {
+ const T = transfersByGw[activeGW]?.count || 0;
+ const chip = chipsByGw[activeGW];
+ if (chip === "wc" || chip === "fh") return 0;
+ const startFt = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
+ return Math.max(0, T - startFt);
+ }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw]);
+
+ // 1. Standard state (can default to your hardcoded defaults initially)
+ const [isCloudLoaded, setIsCloudLoaded] = useState(false);
+
+ // 1. Fetch from Cloud on Mount / Login
+ useEffect(() => {
+ if (teamId && !isCloudLoaded) {
+ loadSettingsFromCloud(teamId).then((cloudData) => {
+ if (cloudData) {
+ if (cloudData.quick) {
+ setQuickSettings(prev => ({ ...prev, ...cloudData.quick }));
+ }
+ // THE FIX: Use setComprehensiveSettings!
+ if (cloudData.advanced) {
+ setComprehensiveSettings(prev => ({ ...prev, ...cloudData.advanced }));
+ }
+ }
+ setIsCloudLoaded(true);
+ });
+ }
+ }, [teamId]);
+
+ // 2. Save to Cloud (DEBOUNCED)
+ useEffect(() => {
+ if (teamId && isCloudLoaded) {
+ const timerId = setTimeout(() => {
+ // THE FIX: Pass comprehensiveSettings instead of advancedSettings!
+ saveSettingsToCloud(teamId, quickSettings, comprehensiveSettings);
+ }, 500);
+ return () => clearTimeout(timerId);
+ }
+ // THE FIX: Watch comprehensiveSettings in the dependency array!
+ }, [quickSettings, comprehensiveSettings, teamId, isCloudLoaded]);
+
+ const getValidLayout = (players, gw) => {
+ if (!players || players.length !== 15) return null;
+ const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${gw}_Pts`]) || 0);
+
+ let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getEV(b) - getEV(a));
+ let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getEV(b) - getEV(a));
+ let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getEV(b) - getEV(a));
+ let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getEV(b) - getEV(a));
+
+ const starters = [];
+ if (gks.length) starters.push(gks.shift());
+ starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1));
+
+ const remaining = [...defs, ...mids, ...fwds].sort((a, b) => getEV(b) - getEV(a));
+ starters.push(...remaining.splice(0, 11 - starters.length));
+
+ const finalStarters = [
+ ...starters.filter((p) => p.Pos === "G"),
+ ...starters.filter((p) => p.Pos === "D"),
+ ...starters.filter((p) => p.Pos === "M"),
+ ...starters.filter((p) => p.Pos === "F"),
+ ];
+
+ const benchGk = gks.length ? gks[0] : null;
+ const benchRest = remaining.sort((a, b) => getEV(b) - getEV(a));
+ const bench = benchGk ? [benchGk, ...benchRest] : benchRest;
+ const topStarters = [...finalStarters].sort((a, b) => getEV(b) - getEV(a));
+
+ return { optimalArray: [...finalStarters, ...bench], cap: topStarters[0]?.ID, vice: topStarters[1]?.ID };
+ };
+
+ const derivedItb = useMemo(() => {
+ let currentBank = baselineItb;
+ if (!availableGWs || availableGWs.length === 0) return currentBank;
+ for (let gw = availableGWs[0]; gw <= activeGW; gw++) {
+ if (gw < activeGW && chipsByGw[gw] === "fh") continue;
+ if (transfersByGw[gw]) currentBank += transfersByGw[gw].netDelta || 0;
+ }
+ return currentBank;
+ }, [activeGW, availableGWs, baselineItb, transfersByGw, chipsByGw]);
+
+ const currentRemainingFts = useMemo(() => {
+ if (!availableGWs || availableGWs.length === 0) return baselineFt;
+ const startingFts = ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
+ const usedThisWeek = transfersByGw[activeGW]?.count || 0;
+ return Math.max(0, startingFts - usedThisWeek);
+ }, [activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw, ftAtStartOfGw]);
+
+ useEffect(() => {
+ fetch("https://anayshukla-fpl-solver.hf.space/api/fixtures").then((res) => res.json()).then(setFixtures).catch(() => { });
+ fetch("https://anayshukla-fpl-solver.hf.space/api/solver/default-settings").then((r) => (r.ok ? r.json() : {})).then((d) => {
+ if (d && typeof d === "object") {
+ setComprehensiveSettings(prev => ({ ...d, ...prev }));
+ }
+ }).catch(() => { });
+ }, []);
+
+ useEffect(() => { setItb(derivedItb); setAvailableFts(currentRemainingFts); }, [derivedItb, currentRemainingFts, setItb, setAvailableFts]);
+ useEffect(() => { if (horizon > maxAvailableHorizon && maxAvailableHorizon > 0) setHorizon(maxAvailableHorizon); }, [maxAvailableHorizon, horizon]);
+
+ useEffect(() => {
+ if (!isSolving) { setSolveElapsedSec(0); return; }
+ const t0 = Date.now();
+ const id = setInterval(() => setSolveElapsedSec(Math.floor((Date.now() - t0) / 1000)), 250);
+ const prev = document.body.style.overflow;
+ document.body.style.overflow = "hidden";
+ return () => { clearInterval(id); document.body.style.overflow = prev; };
+ }, [isSolving]);
+
+ useEffect(() => {
+ if (isLoggedIn && userProfile.defaultTeamId && String(userProfile.defaultTeamId) === String(teamId) && availableGWs.length === 0 && !isLoading) {
+ fetchTeam(null, teamData.length > 0);
+ }
+ }, [isLoggedIn, userProfile.defaultTeamId, teamId, availableGWs.length, teamData.length]);
+
+ const fetchTeam = async (e, preserveState = false) => {
+ // If manually clicked by user, always wipe the slate clean
+ if (e) { e.preventDefault(); setManualOverrides({}); preserveState = false; }
+
+ if (!teamId) return;
+ setIsLoading(true); setError(null);
+ try {
+ const res = await fetch(`https://anayshukla-fpl-solver.hf.space/api/manager/${teamId}`);
+ if (!res.ok) throw new Error("Could not fetch team.");
+ const data = await res.json();
+
+ if (data.picks && data.picks.length > 0) {
+ // 1. ALWAYS populate the strict baseline vault and logic
+ pristineSquadRef.current = {};
+ data.picks.forEach(p => {
+ pristineSquadRef.current[p.ID] = { ...p };
+ });
+ setBaselineItb(data.in_the_bank || 0);
+ setBaselineFt(typeof data.free_transfers === "number" ? data.free_transfers : 1);
+ setInitialSquadIds(data.picks.map((p) => p.ID));
+
+ const gws = Object.keys(data.picks[0]).filter((k) => k.includes("_Pts")).map((k) => parseInt(k.split("_")[0])).sort((a, b) => a - b);
+ setAvailableGWs(gws);
+
+ // 2. ONLY overwrite the squad arrays if we are NOT loading from a saved DB Draft
+ if (!preserveState) {
+ setTransfersByGw({}); setHighlightTransferIds({}); setSolverTransferPairs({}); setSolverApplySnapshot(null); setChipsByGw({}); setChipSolveSolutions([]);
+ setActiveGW(gws[0]);
+ const opt = getValidLayout(data.picks, gws[0]);
+ if (opt) { setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); }
+ else { setTeamData(data.picks); }
+ } else {
+ // If preserving state, just ensure activeGW doesn't break if the draft lacked it
+ if (!activeGW) setActiveGW(gws[0]);
+ }
+
+ setLastLoadedId(teamId);
+ if (isLoggedIn && userProfile.defaultTeamId !== parseInt(teamId)) { setPendingTeamId(parseInt(teamId)); setShowIdPrompt(true); }
+ }
+ } catch (err) { setError(err.message); } finally { setIsLoading(false); }
+ };
+
+ useEffect(() => {
+ if (!teamData.length || !activeGW || teamData.some((p) => p.isBlank && !String(p.ID).startsWith("blank_"))) return;
+
+ const gwLock = manualOverrides[activeGW];
+
+ if (gwLock && gwLock.ids) {
+ let reconstructed = gwLock.ids.map((id) => {
+ if (String(id).startsWith("blank_")) {
+ const replaced = gwLock.manualTransfers?.[id];
+ return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced };
+ }
+
+ let found = hydratePlayer(id);
+ if (found && gwLock.manualTransfers && gwLock.manualTransfers[id]) {
+ found.replacedPlayer = gwLock.manualTransfers[id];
+ }
+ return found;
+ }).filter(Boolean);
+
+ if (reconstructed.length !== 15) {
+ if (globalPlayers.length > 0) {
+ setManualOverrides((prev) => { const n = { ...prev }; delete n[activeGW]; return n; });
+ }
+ return;
+ }
+
+ // THE FIX: Auto-optimize lineup safely AFTER global EVs finish recalculating!
+ if (pendingAutoReset) {
+ const opt = getValidLayout(reconstructed, activeGW);
+ if (opt) {
+ setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice } }));
+ setTeamData(opt.optimalArray);
+ setCaptainId(opt.cap);
+ setViceId(opt.vice);
+ }
+ setPendingAutoReset(false);
+ return;
+ }
+
+ let needsSub = false;
+ const getEV = (p) => Number(p[`${activeGW}_Pts`]) || 0;
+
+ for (let i = 0; i < 11; i++) {
+ const starter = reconstructed[i];
+ if (starter.isBlank || (getEV(starter) === 0 && (!gwLock.forcedZeros || !gwLock.forcedZeros.includes(starter.ID)))) {
+ const bestBenchIdx = [11, 12, 13, 14].find((bIdx) => {
+ const bPlayer = reconstructed[bIdx];
+ if (bPlayer.isBlank || getEV(bPlayer) <= 0) return false;
+
+ const tempStarters = [...reconstructed.slice(0, 11)];
+ tempStarters[i] = bPlayer;
+ const counts = { G: 0, D: 0, M: 0, F: 0 };
+ tempStarters.forEach(p => { if (p.Pos) counts[p.Pos]++; });
+
+ if (counts.G !== 1 || counts.D < 3 || counts.M < 2 || counts.F < 1) return false;
+ return true;
+ });
+
+ if (bestBenchIdx) {
+ const temp = reconstructed[i];
+ reconstructed[i] = reconstructed[bestBenchIdx];
+ reconstructed[bestBenchIdx] = temp;
+ needsSub = true;
+ }
+ }
+ }
+
+ if (needsSub) {
+ setManualOverrides((prev) => ({ ...prev, [activeGW]: { ...gwLock, ids: reconstructed.map((p) => p.ID) } }));
+ }
+
+ setTeamData(reconstructed);
+ setCaptainId(gwLock.cap);
+ setViceId(gwLock.vice);
+
+ } else {
+ let deterministicIds = [...initialSquadIds];
+ if (availableGWs && availableGWs.length > 0) {
+ for (let gw = availableGWs[0]; gw < activeGW; gw++) {
+ if (manualOverrides[gw] && chipsByGw[gw] !== "fh") deterministicIds = manualOverrides[gw].ids;
+ }
+ }
+
+ const deterministicSquad = deterministicIds.map(id => hydratePlayer(id)).filter(Boolean);
+
+ const opt = getValidLayout(deterministicSquad, activeGW);
+ if (opt) {
+ setTeamData(opt.optimalArray);
+ setCaptainId(opt.cap);
+ setViceId(opt.vice);
+ }
+ }
+ }, [globalPlayers, activeGW, teamData.length, manualOverrides, pendingAutoReset]);
+
+ const activeGwEV = useMemo(() => {
+ if (!teamData.length || !activeGW) return 0;
+ const chip = chipsByGw[activeGW];
+ const capMult = chip === "tc" ? 3 : 2;
+ let total = 0;
+ teamData.slice(0, 11).forEach((p) => { if (!p.isBlank) total += (Number(p[`${activeGW}_Pts`]) || 0) * (p.ID === captainId ? capMult : 1); });
+
+ let ofIdx = 0;
+ teamData.slice(11, 15).forEach((p) => {
+ if (!p.isBlank) {
+ if (chip === "bb") {
+ total += (Number(p[`${activeGW}_Pts`]) || 0);
+ } else if (p.Pos === "G") {
+ total += (Number(p[`${activeGW}_Pts`]) || 0) * 0.04;
+ } else {
+ const bw = [0.17, 0.05, 0.02][ofIdx] || 0.02;
+ total += (Number(p[`${activeGW}_Pts`]) || 0) * bw;
+ ofIdx++;
+ }
+ }
+ });
+ return total - hitsThisGw * HIT_COST;
+ }, [teamData, activeGW, captainId, hitsThisGw, chipsByGw]);
+
+ const horizonEvData = useMemo(() => {
+ if (!teamData.length || !horizonGWs.length) return { total: 0, breakdown: {} };
+ let total = 0;
+ const breakdown = {};
+
+ // Helper to get the actual squad that existed at the start of a specific GW
+ const getSquadForGw = (targetGw) => {
+ // If it's the active GW or in the future, the current teamData is our baseline
+ if (targetGw >= activeGW) return teamData;
+
+ // If it's in the past, rebuild it from the permanent chain
+ let deterministicIds = [...initialSquadIds];
+ if (availableGWs && availableGWs.length > 0) {
+ for (let gw = availableGWs[0]; gw <= targetGw; gw++) {
+ if (manualOverrides[gw] && chipsByGw[gw] !== "fh") {
+ deterministicIds = manualOverrides[gw].ids;
+ }
+ }
+ }
+ return deterministicIds.map(id => hydratePlayer(id)).filter(Boolean);
+ };
+
+ horizonGWs.forEach((gw) => {
+ let gwPts = 0;
+ const gwChip = chipsByGw[gw];
+ const gwCapMult = gwChip === "tc" ? 3 : 2;
+
+ const applyBenchMath = (benchSlice) => {
+ let ofIdx = 0;
+ benchSlice.forEach((p) => {
+ if (!p.isBlank) {
+ if (gwChip === "bb") {
+ gwPts += (Number(p[`${gw}_Pts`]) || 0);
+ } else if (p.Pos === "G") {
+ gwPts += (Number(p[`${gw}_Pts`]) || 0) * 0.04;
+ } else {
+ const bw = [0.17, 0.05, 0.02][ofIdx] || 0.02;
+ gwPts += (Number(p[`${gw}_Pts`]) || 0) * bw;
+ ofIdx++;
+ }
+ }
+ });
+ };
+
+ // THE FIX: Use the time-accurate squad for this specific GW
+ const gwSpecificSquad = getSquadForGw(gw);
+
+ if (gw === activeGW) {
+ gwSpecificSquad.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === captainId ? gwCapMult : 1); });
+ applyBenchMath(gwSpecificSquad.slice(11, 15));
+ } else {
+ const gwLock = manualOverrides[gw];
+ if (gwLock && gwLock.ids) {
+ const reconstructed = gwLock.ids.map((id) => gwSpecificSquad.find((p) => String(p.ID) === String(id)) || globalPlayers.find((p) => String(p.ID) === String(id))).filter(Boolean);
+ if (reconstructed.length === 15) {
+ reconstructed.slice(0, 11).forEach((p) => { if (!p.isBlank) gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === gwLock.cap ? gwCapMult : 1); });
+ applyBenchMath(reconstructed.slice(11, 15));
+ } else {
+ const opt = getValidLayout(gwSpecificSquad, gw);
+ if (opt) {
+ opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); });
+ applyBenchMath(opt.optimalArray.slice(11, 15));
+ }
+ }
+ } else {
+ const opt = getValidLayout(gwSpecificSquad, gw);
+ if (opt) {
+ opt.optimalArray.slice(0, 11).forEach((p) => { gwPts += (Number(p[`${gw}_Pts`]) || 0) * (p.ID === opt.cap ? gwCapMult : 1); });
+ applyBenchMath(opt.optimalArray.slice(11, 15));
+ }
+ }
+ }
+ const ftStart = ftAtStartOfGw(gw, availableGWs, baselineFt, transfersByGw, chipsByGw);
+ const T = transfersByGw[gw]?.count ?? 0;
+ const isChipFree = gwChip === "wc" || gwChip === "fh";
+ const hits = isChipFree ? 0 : Math.max(0, T - ftStart);
+ const ev = gwPts - hits * HIT_COST;
+
+ total += ev;
+ breakdown[gw] = { ev, chip: gwChip, hits, ftStart, moves: T, isChipFree };
+ });
+ return { total, breakdown };
+ }, [teamData, horizonGWs, activeGW, captainId, manualOverrides, baselineFt, transfersByGw, chipsByGw, globalPlayers, initialSquadIds, availableGWs]);
+
+ const horizonEV = horizonEvData.total;
+
+ // Sync the breakdown to the active draft automatically
+ useEffect(() => {
+ if (Object.keys(horizonEvData.breakdown).length === 0) return;
+ setDrafts(prev => {
+ const activeIdx = prev.findIndex(d => d.id === activeDraftId);
+ if (activeIdx === -1) return prev;
+ const currentCached = prev[activeIdx].cachedEvs;
+ if (JSON.stringify(currentCached) === JSON.stringify(horizonEvData.breakdown)) return prev;
+
+ const next = [...prev];
+ next[activeIdx] = { ...next[activeIdx], cachedEvs: horizonEvData.breakdown };
+ return next;
+ });
+ }, [horizonEvData.breakdown, activeDraftId, setDrafts]);
+
+ // --- SOLVER API TRIGGERS & BASELINE ENGINE ---
+ const getSolverStartingState = () => {
+ const startGW = solveGWs[0];
+ const startIndex = availableGWs.indexOf(startGW);
+
+ // 1. Get Starting Squad (The exact squad going INTO the solve horizon, before current manual moves)
+ let startingIds = initialSquadIds;
+ if (startIndex > 0) {
+ const prevGw = availableGWs[startIndex - 1];
+ startingIds = manualOverrides[prevGw]?.ids || initialSquadIds;
+ }
+
+ const startingSquad = startingIds.map(id => {
+ // Try to keep exact FPL prices from current teamData
+ const existing = teamData.find(t => String(t.ID) === String(id));
+ if (existing) return existing;
+ const g = globalPlayers.find(x => String(x.ID) === String(id));
+ return g ? { ...g, Price: getPlayerPrice(g) } : null;
+ }).filter(Boolean);
+
+ // 2. Get Starting ITB (Bank BEFORE the current gameweek's moves)
+ let startingItb = baselineItb;
+ for (let i = 0; i < startIndex; i++) {
+ const gw = availableGWs[i];
+ if (chipsByGw[gw] === "fh") continue;
+ if (transfersByGw[gw]) startingItb += transfersByGw[gw].netDelta || 0;
+ }
+
+ // 3. Get Starting FTs (FTs going INTO the horizon)
+ const startingFts = ftAtStartOfGw(startGW, availableGWs, baselineFt, transfersByGw, chipsByGw);
+
+ // 4. Extract Manual Moves as Booked Transfers
+ const bookedTransfers = [];
+ solveGWs.forEach(gw => {
+ const lock = manualOverrides[gw];
+ if (lock && lock.manualTransfers) {
+ Object.entries(lock.manualTransfers).forEach(([inId, outPlayer]) => {
+ if (!String(inId).startsWith("blank_") && outPlayer) {
+ bookedTransfers.push({
+ gw: Number(gw),
+ transfer_in: Number(inId),
+ transfer_out: Number(outPlayer.ID)
+ });
+ }
+ });
+ }
+ });
+
+ return { startingSquad, startingItb, startingFts, bookedTransfers };
+ };
+
+
+ const getActiveCompSettings = (bookedTransfers) => {
+ let payload;
+
+ // If OFF: Send the absolute baseline defaults from our frontend UI + the manual moves
+ if (!comprehensiveSettings.enabled) {
+ payload = { ...DEFAULT_SETTINGS, booked_transfers: bookedTransfers };
+ }
+ // If ON: Send the user's custom edited settings + the manual moves
+ else {
+ payload = { ...comprehensiveSettings, booked_transfers: bookedTransfers };
+ }
+
+ // Safety check: If they aren't using the advanced FT list, strip it so backend uses the flat value
+ if (!payload.use_ft_value_list) {
+ delete payload.ft_value_list;
+ }
+
+ return payload;
+ };
+
+ // --- THE XMINS FILTER ENGINE ---
+ // Replicates open-fpl-solver logic BEFORE sending the payload to Python
+ const getFilteredGlobalPlayers = (startingSquad, bookedTransfers) => {
+ const activeSettings = getActiveCompSettings(bookedTransfers);
+ const xminLbPerGw = activeSettings.xmin_lb || 0;
+
+ // If the setting is 0 or disabled, skip filtering
+ if (xminLbPerGw <= 0) return globalPlayers;
+
+ // Multiply the input by the horizon length to get the total threshold
+ const totalXminThreshold = xminLbPerGw * horizonGWs.length;
+
+ // Build the "safe_players" array (Current squad + anyone you manually locked in)
+ const safePlayers = new Set(startingSquad.map(p => String(p.ID)));
+ bookedTransfers.forEach(bt => {
+ safePlayers.add(String(bt.transfer_in));
+ safePlayers.add(String(bt.transfer_out));
+ });
+
+ // Execute the filter: (total_min >= xmin_lb) | (ID in safe_players)
+ return globalPlayers.filter(p => {
+ if (safePlayers.has(String(p.ID))) return true;
+
+ let totalMins = 0;
+ horizonGWs.forEach(gw => {
+ totalMins += (Number(p[`${gw}_xMins`]) || 0);
+ });
+
+ return totalMins >= totalXminThreshold;
+ });
+ };
+
+ const runMainSolver = () => {
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
+
+ // THE FIX: Calculate apples-to-apples baseline EV for the active window, and the past locked EV
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+
+ apiHandleSolve({
+ teamId, solveGWs, horizonGWs, teamData: startingSquad,
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
+ itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw,
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers),
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
+ });
+ };
+
+ const runSensAnalysis = () => {
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
+
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+
+ apiHandleSensAnalysis({
+ teamId, solveGWs, horizonGWs, teamData: startingSquad,
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
+ itb: startingItb, availableFts: startingFts, advancedSettings, quickSettings, chipsByGw,
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers), numSims,
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
+ });
+ };
+
+ const runChipSolve = () => {
+ const { startingSquad, startingItb, startingFts, bookedTransfers } = getSolverStartingState();
+
+ const lockedBaselineEv = solveGWs.reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+ const pastBaselineEv = horizonGWs.filter(gw => !solveGWs.includes(gw)).reduce((sum, gw) => sum + (horizonEvData.breakdown[gw]?.ev || 0), 0);
+
+ apiHandleChipSolve({
+ teamId, horizonGWs, teamData: startingSquad,
+ globalPlayers: getFilteredGlobalPlayers(startingSquad, bookedTransfers),
+ itb: startingItb, availableFts: startingFts, advancedSettings,
+ comprehensiveSettings: getActiveCompSettings(bookedTransfers), chipSolveOptions,
+ lockedBaselineEv, pastBaselineEv // <-- INJECTED HERE
+ });
+ };
+
+ // --- MULTIVERSE DRAFT HANDLERS ---
+ const handleCloneDraft = () => {
+ if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; }
+ const currentDraft = drafts.find(d => d.id === activeDraftId);
+ const newId = `draft_${Date.now()}`;
+
+ // THE FIX: Deep clone ALL objects to stop timelines from sharing memory
+ const newDraft = {
+ ...currentDraft,
+ id: newId,
+ name: `${currentDraft.name} (Copy)`,
+ fixtureOverrides: JSON.parse(JSON.stringify(currentDraft.fixtureOverrides || {})),
+ sessionEdits: JSON.parse(JSON.stringify(currentDraft.sessionEdits || {})),
+ manualOverrides: JSON.parse(JSON.stringify(currentDraft.manualOverrides || {})),
+ transfersByGw: JSON.parse(JSON.stringify(currentDraft.transfersByGw || {})),
+ highlightTransferIds: JSON.parse(JSON.stringify(currentDraft.highlightTransferIds || {})),
+ solverTransferPairs: JSON.parse(JSON.stringify(currentDraft.solverTransferPairs || {})),
+ chipsByGw: JSON.parse(JSON.stringify(currentDraft.chipsByGw || {})),
+ cachedEvs: JSON.parse(JSON.stringify(currentDraft.cachedEvs || {}))
+ };
+
+ setDrafts(prev => [...prev, newDraft]);
+ setActiveDraftId(newId);
+ };
+
+ const handleNewDraft = () => {
+ if (drafts.length >= 5) { alert("Maximum of 5 realities allowed."); return; }
+ const newId = `draft_${Date.now()}`;
+ const startGW = availableGWs[0] || activeGW;
+ const pristineSquad = initialSquadIds.map(id => hydratePlayer(id)).filter(Boolean);
+ const opt = getValidLayout(pristineSquad, startGW);
+ const finalSquad = opt ? opt.optimalArray : pristineSquad;
+
+ const newDraft = {
+ 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: {}
+ };
+ setDrafts(prev => [...prev, newDraft]);
+ setActiveDraftId(newId);
+ };
+ // --- TIMELINE WIPING HELPER ---
+ // If you manually edit the pitch, any "future" moves planned by the solver MUST be
+ // wiped out so that the timeline correctly cascades forward!
+ const clearFuture = (prev) => {
+ return prev; // 👈 FIXED: We no longer wipe out future gameweek plans!
+ };
+
+ const applySolution = (sol) => {
+ setSolverApplySnapshot({
+ teamData: [...teamData], availableFts, transfersByGw: { ...transfersByGw }, manualOverrides: { ...manualOverrides }, baselineItb, baselineFt
+ });
+
+ const newOverrides = { ...manualOverrides };
+ const newTransfersByGw = { ...transfersByGw };
+ const newChipsByGw = { ...chipsByGw };
+ const newHighlights = { ...highlightTransferIds };
+ const newPairs = { ...solverTransferPairs };
+
+ sol.plan.forEach(gwPlan => {
+ const gw = gwPlan.gw;
+ const getPts = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? (Number(p[`${gw}_Pts`]) || 0) : 0; };
+ const posOrder = { G: 1, D: 2, M: 3, F: 4 };
+ const getPos = (id) => { const p = globalPlayers.find(x => String(x.ID) === String(id)); return p ? posOrder[p.Pos] || 5 : 5; };
+
+ let activeLineup = [...gwPlan.lineup];
+ let activeBench = [...gwPlan.bench];
+ const isBB = sol.chips_used && sol.chips_used[String(gw)] === "bb";
+
+ // BUG 2 FIX: Auto-optimize the BB lineup visually so best players start
+ if (isBB) {
+ const all15 = [...activeLineup, ...activeBench];
+ const pObjs = all15.map(id => {
+ const p = globalPlayers.find(x => String(x.ID) === String(id));
+ return p ? { ...p, temp_pts: getPts(id) } : { ID: id, Pos: 'M', temp_pts: 0 };
+ });
+
+ let gks = pObjs.filter(p => p.Pos === "G").sort((a, b) => b.temp_pts - a.temp_pts);
+ let defs = pObjs.filter(p => p.Pos === "D").sort((a, b) => b.temp_pts - a.temp_pts);
+ let mids = pObjs.filter(p => p.Pos === "M").sort((a, b) => b.temp_pts - a.temp_pts);
+ let fwds = pObjs.filter(p => p.Pos === "F").sort((a, b) => b.temp_pts - a.temp_pts);
+
+ const starters = [];
+ if (gks.length) starters.push(gks.shift());
+ starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1));
+
+ const remaining = [...defs, ...mids, ...fwds].sort((a, b) => b.temp_pts - a.temp_pts);
+ starters.push(...remaining.splice(0, 11 - starters.length));
+
+ activeLineup = starters.map(p => p.ID);
+ activeBench = [gks[0]?.ID, ...remaining.map(p => p.ID)].filter(Boolean);
+ }
+
+ const sortedLineup = activeLineup.sort((a, b) => {
+ const posDiff = getPos(a) - getPos(b);
+ if (posDiff !== 0) return posDiff;
+ return getPts(b) - getPts(a);
+ });
+
+ newOverrides[gw] = { ids: [...sortedLineup, ...activeBench], cap: gwPlan.captain, vice: gwPlan.vice_captain, forcedZeros: [] };
+ if (sol.chips_used && sol.chips_used[String(gw)]) newChipsByGw[gw] = sol.chips_used[String(gw)];
+
+ if (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) {
+ const netDelta = gwPlan.transfers_out.reduce((sum, id) => {
+ const squadP = teamData.find(p => String(p.ID) === String(id));
+ return sum + (squadP && squadP.Price ? squadP.Price : getPlayerPrice(globalPlayers.find(p => String(p.ID) === String(id))));
+ }, 0) - gwPlan.transfers_in.reduce((sum, id) => sum + (getPlayerPrice(globalPlayers.find(p => String(p.ID) === String(id))) || 0), 0);
+
+ newTransfersByGw[gw] = { count: gwPlan.transfers_in.length, hits: gwPlan.hits, netDelta: netDelta, inIds: gwPlan.transfers_in, outIds: gwPlan.transfers_out };
+ newHighlights[gw] = [...gwPlan.transfers_in];
+ const newManualTransfersForGw = {};
+ gwPlan.transfers_in.forEach((inId, idx) => {
+ const outId = gwPlan.transfers_out[idx];
+
+ const preSolveP = teamData.find(p => String(p.ID) === String(outId));
+ let outP;
+
+ if (preSolveP) {
+ outP = preSolveP;
+ } else {
+ const gMatch = globalPlayers.find(p => String(p.ID) === String(outId));
+ const marketCost = gMatch ? (gMatch.now_cost !== undefined ? gMatch.now_cost : gMatch.Price) : 0;
+ outP = gMatch ? { ...gMatch, purchase_price: marketCost, selling_price: marketCost, Price: marketCost } : null;
+ }
+
+ if (outP) {
+ const finalOutPlayer = { ...outP, Price: getPlayerPrice(outP) };
+ newManualTransfersForGw[inId] = finalOutPlayer;
+ }
+ });
+
+ // THE FIX: Delete the solver memory so it doesn't double-render alongside the manual memory!
+ delete newPairs[gw];
+
+ // CLEAN FIX: Attach the transfers directly to the master object we are building
+ // No more setState calls fighting each other inside a loop!
+ newOverrides[gw] = {
+ ...newOverrides[gw],
+ manualTransfers: {
+ ...(newOverrides[gw]?.manualTransfers || {}),
+ ...newManualTransfersForGw
+ }
+ };
+
+ // Chips are now handled properly too
+ if (gwPlan.chip) newChipsByGw[gw] = gwPlan.chip;
+
+ } else {
+ delete newTransfersByGw[gw];
+ delete newHighlights[gw];
+ delete newPairs[gw];
+ }
+ });
+
+ setManualOverrides(newOverrides); setTransfersByGw(newTransfersByGw); setChipsByGw(newChipsByGw); setHighlightTransferIds(newHighlights); setSolverTransferPairs(newPairs);
+
+ setAppliedPlanSummary({
+ horizon: `GW${sol.horizon_gws[0]} - GW${sol.horizon_gws[sol.horizon_gws.length - 1]}`,
+ ev: sol.ev,
+ objectiveScore: sol.objective_score,
+ plan: sol.plan,
+ lockedBaselineEv: horizonEV,
+ transfers: sol.plan.map(p => ({
+ gw: p.gw, chip: p.chip, itb: p.itb, hits: p.hits, ft_at_start: p.ft_at_start,
+ outs: p.transfers_out.map(id => globalPlayers.find(x => x.ID === id)?.Name || id),
+ ins: p.transfers_in.map(id => globalPlayers.find(x => x.ID === id)?.Name || id)
+ }))
+ });
+
+ if (sol.plan.length > 0) {
+ const activePlan = sol.plan.find(p => p.gw === activeGW) || sol.plan[0];
+ let nextSquad = [...teamData];
+ if (activePlan.transfers_in.length > 0) {
+ activePlan.transfers_in.forEach((inId, idx) => {
+ const pIn = globalPlayers.find(p => String(p.ID) === String(inId));
+ const outIndex = nextSquad.findIndex(p => String(p.ID) === String(activePlan.transfers_out[idx]));
+ if (outIndex !== -1 && pIn) nextSquad[outIndex] = { ...pIn, Price: getPlayerPrice(pIn) };
+ });
+ } else {
+ nextSquad = [...activePlan.lineup, ...activePlan.bench].map(id => {
+ const existing = teamData.find(t => String(t.ID) === String(id));
+ const hydrated = hydratePlayer(id);
+ if (existing && hydrated) return { ...hydrated, replacedPlayer: existing.replacedPlayer };
+ return hydrated;
+ }).filter(Boolean);
+ }
+
+ const getPts = (p) => Number(p[`${activePlan.gw}_Pts`]) || 0;
+ const finalLineup = activePlan.lineup.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean);
+ const finalBench = activePlan.bench.map(id => nextSquad.find(p => String(p.ID) === String(id))).filter(Boolean);
+
+ const sortedStarters = [
+ ...finalLineup.filter(p => p.Pos === "G").sort((a, b) => getPts(b) - getPts(a)),
+ ...finalLineup.filter(p => p.Pos === "D").sort((a, b) => getPts(b) - getPts(a)),
+ ...finalLineup.filter(p => p.Pos === "M").sort((a, b) => getPts(b) - getPts(a)),
+ ...finalLineup.filter(p => p.Pos === "F").sort((a, b) => getPts(b) - getPts(a)),
+ ];
+ const sortedBench = [
+ ...finalBench.filter(p => p.Pos === "G"),
+ ...finalBench.filter(p => p.Pos !== "G").sort((a, b) => getPts(b) - getPts(a))
+ ];
+
+ setTeamData([...sortedStarters, ...sortedBench]);
+ setCaptainId(activePlan.captain); setViceId(activePlan.vice_captain);
+ }
+ setPendingSolutions([]);
+ };
+
+ const updateFutureTimelines = (oldSquad, newSquad, currentOverrides, currentTransfers, currentPairs, customMapping = null) => {
+ let mapping = {};
+ let removedIds = [];
+ let addedIds = [];
+
+ if (customMapping) {
+ Object.keys(customMapping).forEach(k => {
+ mapping[String(k)] = String(customMapping[k]);
+ removedIds.push(String(k));
+ addedIds.push(String(customMapping[k]));
+ });
+ } else {
+ const oldIds = oldSquad.map(p => String(p.ID));
+ const newIds = newSquad.map(p => String(p.ID));
+ removedIds = oldIds.filter(id => !newIds.includes(id) && !id.startsWith("blank_"));
+ addedIds = newIds.filter(id => !oldIds.includes(id) && !id.startsWith("blank_"));
+ for (let i = 0; i < Math.min(removedIds.length, addedIds.length); i++) {
+ mapping[removedIds[i]] = addedIds[i];
+ }
+ }
+
+ const nextOverrides = { ...currentOverrides };
+ const nextTransfers = { ...currentTransfers };
+ const nextPairs = { ...currentPairs };
+
+ for (let gw = activeGW + 1; gw <= Math.max(...(availableGWs || [])); gw++) {
+
+ // 1. SURGICAL SCRUB: Remove redundant "Buy" plans to kill the ghost button,
+ // but DO NOT filter "outIds" so the Y->Z to X->Z cascade survives perfectly!
+ if (nextTransfers[gw]) {
+ nextTransfers[gw].inIds = (nextTransfers[gw].inIds || []).filter(id => !addedIds.includes(String(id)));
+ nextTransfers[gw].count = nextTransfers[gw].inIds.length;
+ if (nextTransfers[gw].count === 0) delete nextTransfers[gw];
+ }
+
+ if (nextOverrides[gw]) {
+ const lock = nextOverrides[gw];
+ const updatedIds = lock.ids.map(id => mapping[String(id)] || String(id));
+
+ // Anti-Time-Paradox: Only wipe the GW if the cascade creates literal duplicate players
+ const uniqueIds = new Set(updatedIds);
+ if (uniqueIds.size !== updatedIds.length) {
+ delete nextOverrides[gw];
+ delete nextTransfers[gw];
+ delete nextPairs[gw];
+
+ // THE FIX: Plunge the timeline into darkness so the UI doesn't glow for a deleted GW!
+ setHighlightTransferIds(prev => { const n = { ...prev }; delete n[gw]; return n; });
+ setTransfersByGw(prev => { const n = { ...prev }; delete n[gw]; return n; });
+
+ continue;
+ }
+
+ const updatedTransfers = {};
+ if (lock.manualTransfers) {
+ for (const [inId, outPlayer] of Object.entries(lock.manualTransfers)) {
+ // KILL OBSOLETE MOVES & GLOWS: Skip this move if the player is already naturally in the incoming squad
+ if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) {
+
+ // FIX: highlightTransferIds is an object of arrays. Target the specific GW array.
+ setHighlightTransferIds(prev => ({
+ ...prev,
+ [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId))
+ }));
+
+ // FIX: transfersByGw is an object of objects. Safely reduce the count.
+ setTransfersByGw(prev => {
+ const currentGwTransfers = prev[gw];
+ if (!currentGwTransfers) return prev;
+
+ const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId));
+ const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1);
+
+ if (newCount === 0) {
+ const next = { ...prev };
+ delete next[gw];
+ return next;
+ }
+ return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } };
+ });
+
+ continue;
+ }
+
+ let newOutPlayer = outPlayer;
+ const outIdStr = String(outPlayer?.ID);
+ if (outPlayer && mapping[outIdStr]) {
+ const mappedId = mapping[outIdStr];
+ let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId);
+ if (mappedP) newOutPlayer = { ...mappedP, Price: getPlayerPrice(mappedP) };
+ }
+ updatedTransfers[mapping[String(inId)] || String(inId)] = newOutPlayer;
+ }
+ }
+
+ // --- RE-OPTIMIZE LINEUP FOR FUTURE GAMEWEEKS ---
+ // Instantly sub out the cascaded player if their EV is bad in this future gameweek
+ const reconstructedSquad = updatedIds.map(id => {
+ if (String(id).startsWith("blank_")) {
+ const replaced = updatedTransfers[id];
+ return { ID: id, isBlank: true, Pos: replaced?.Pos || "M", Name: "", Team: "", Price: 0, replacedPlayer: replaced };
+ }
+ return hydratePlayer(id);
+ }).filter(Boolean);
+
+ const opt = getValidLayout(reconstructedSquad, gw);
+
+ nextOverrides[gw] = {
+ ...lock,
+ ids: opt ? opt.optimalArray.map(p => p.ID) : updatedIds,
+ manualTransfers: updatedTransfers,
+ cap: opt ? opt.cap : mapping[String(lock.cap)] || lock.cap,
+ vice: opt ? opt.vice : mapping[String(lock.vice)] || lock.vice
+ };
+ }
+
+ if (nextPairs[gw]) {
+ const updatedGwPairs = {};
+ for (const [inId, pairData] of Object.entries(nextPairs[gw])) {
+ // KILL GHOST BUTTON & GLOW: Skip this solver memory if the player naturally returns to squad
+ if (addedIds.includes(String(inId)) || newSquad.some(p => String(p.ID) === String(inId))) {
+ setHighlightTransferIds(prev => ({ ...prev, [gw]: Array.from(prev[gw] || []).filter(id => String(id) !== String(inId)) }));
+ setTransfersByGw(prev => {
+ const currentGwTransfers = prev[gw];
+ if (!currentGwTransfers) return prev;
+ const newInIds = Array.from(currentGwTransfers.inIds || []).filter(id => String(id) !== String(inId));
+ const newCount = Math.max(0, (currentGwTransfers.count || 1) - 1);
+ if (newCount === 0) { const next = { ...prev }; delete next[gw]; return next; }
+ return { ...prev, [gw]: { ...currentGwTransfers, inIds: newInIds, count: newCount } };
+ });
+ continue;
+ }
+
+ let newOut = pairData.outPlayer;
+ const outIdStr = String(newOut?.ID);
+ if (newOut && mapping[outIdStr]) {
+ const mappedId = mapping[outIdStr];
+ let mappedP = globalPlayers.find(p => String(p.ID) === mappedId) || newSquad.find(p => String(p.ID) === mappedId);
+ if (mappedP) newOut = { ...mappedP, Price: getPlayerPrice(mappedP) };
+ }
+ updatedGwPairs[mapping[String(inId)] || String(inId)] = { outPlayer: newOut };
+ }
+
+ if (Object.keys(updatedGwPairs).length === 0) {
+ delete nextPairs[gw];
+ } else {
+ nextPairs[gw] = updatedGwPairs;
+ }
+ }
+ }
+ return { nextOverrides, nextTransfers, nextPairs };
+ };
+
+ const handleDragStart = (event) => setActiveDragPlayer(event.active.data.current.player);
+
+ const isValidSwap = (p1, p2) => {
+ if (!p1 || !p2 || p1.isBlank || p2.isBlank) return false;
+ if (p1.ID === p2.ID) return true;
+ if (p1.Pos === "G" && p2.Pos !== "G") return false;
+ if (p1.Pos !== "G" && p2.Pos === "G") return false;
+ const currentStarters = teamData.slice(0, 11);
+ const isP1Starter = currentStarters.some((p) => p.ID === p1.ID);
+ const isP2Starter = currentStarters.some((p) => p.ID === p2.ID);
+ if (isP1Starter === isP2Starter) return true;
+ const newStarters = currentStarters.filter((p) => p.ID !== p1.ID && p.ID !== p2.ID);
+ newStarters.push(isP1Starter ? p2 : p1);
+ const counts = { G: 0, D: 0, M: 0, F: 0 };
+ newStarters.forEach((p) => counts[p.Pos]++);
+ return counts.G === 1 && counts.D >= 3 && counts.M >= 2 && counts.F >= 1 && newStarters.length === 11;
+ };
+
+ const handleDragEnd = (event) => {
+ const { active, over } = event;
+ setActiveDragPlayer(null);
+ if (over && active.id !== over.id) {
+ const p1 = active.data.current.player; const p2 = over.data.current.player;
+ if (isValidSwap(p1, p2)) {
+ const newArr = [...teamData];
+ const idx1 = newArr.findIndex((p) => p.ID === p1.ID);
+ const idx2 = newArr.findIndex((p) => p.ID === p2.ID);
+ newArr[idx1] = p2; newArr[idx2] = p1;
+ const normalized = idx1 >= 11 || idx2 >= 11 ? normalizeBenchGkFirst(newArr, activeGW) : newArr;
+ const forcedZeros = manualOverrides[activeGW]?.forcedZeros || [];
+ if ((Number(p1[`${activeGW}_Pts`]) || 0) === 0 && idx2 < 11) forcedZeros.push(p1.ID);
+ if ((Number(p2[`${activeGW}_Pts`]) || 0) === 0 && idx1 < 11) forcedZeros.push(p2.ID);
+
+ let newCap = captainId; let newVice = viceId;
+ const getEV = (p) => p.isBlank ? -1000 : (Number(p[`${activeGW}_Pts`]) || 0);
+ const starters = normalized.slice(0, 11);
+ const starterIds = starters.map((p) => p.ID);
+
+ if (!starterIds.includes(newCap)) {
+ const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a));
+ newCap = sorted[0]?.ID;
+ if (newCap === newVice) newVice = sorted[1]?.ID;
+ }
+ if (!starterIds.includes(newVice)) {
+ const sorted = [...starters].sort((a, b) => getEV(b) - getEV(a));
+ newVice = sorted.find((p) => p.ID !== newCap)?.ID;
+ }
+
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: normalized.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros } }));
+ setTeamData(normalized);
+
+ setTransfersByGw(clearFuture);
+ setHighlightTransferIds(clearFuture);
+ setSolverTransferPairs(clearFuture);
+ setChipsByGw(clearFuture);
+ setAppliedPlanSummary(null);
+ }
+ }
+ };
+
+ const handleCapChange = (id, type) => {
+ let newCap = captainId; let newVice = viceId;
+ if (type === "C") { newCap = id; if (viceId === id) newVice = captainId; } else { newVice = id; if (captainId === id) newCap = viceId; }
+
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: teamData.map((p) => p.ID), cap: newCap, vice: newVice, forcedZeros: prev[activeGW]?.forcedZeros } }));
+ setCaptainId(newCap); setViceId(newVice);
+
+ setTransfersByGw(clearFuture);
+ setHighlightTransferIds(clearFuture);
+ setSolverTransferPairs(clearFuture);
+ setChipsByGw(clearFuture);
+ setAppliedPlanSummary(null);
+ };
+
+ const handleResetGW = () => {
+ const opt = getValidLayout(teamData, activeGW);
+ if (!opt) return;
+
+ setManualOverrides((prev) => clearFuture({ ...prev, [activeGW]: { ...prev[activeGW], ids: opt.optimalArray.map((p) => p.ID), cap: opt.cap, vice: opt.vice, forcedZeros: prev[activeGW]?.forcedZeros || [] } }));
+ setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice);
+
+ setTransfersByGw(clearFuture);
+ setHighlightTransferIds(clearFuture);
+ setSolverTransferPairs(clearFuture);
+ setChipsByGw(clearFuture);
+ setAppliedPlanSummary(null);
+ };
+
+ const handleChipSelect = (gw, chipType) => {
+ setChipsByGw((prev) => {
+ const next = { ...prev };
+ if (!chipType) { delete next[gw]; } else { Object.keys(next).forEach((g) => { if (next[g] === chipType) delete next[g]; }); next[gw] = chipType; }
+ return clearFuture(next);
+ });
+
+ setManualOverrides(clearFuture);
+ setTransfersByGw(clearFuture);
+ setHighlightTransferIds(clearFuture);
+ setSolverTransferPairs(clearFuture);
+ setAppliedPlanSummary(null);
+ };
+
+ const handleTransferOut = (playerToDrop) => {
+ const sellPrice = getPlayerPrice(playerToDrop);
+ const blankId = `blank_${Date.now()}`;
+ 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);
+
+ const opt = getValidLayout(newSquad, activeGW);
+ const finalSquad = opt ? opt.optimalArray : newSquad;
+
+ let nextTransfers = { ...transfersByGw };
+ nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), netDelta: (nextTransfers[activeGW]?.netDelta || 0) + sellPrice };
+
+ let nextOverrides = { ...manualOverrides };
+ nextOverrides[activeGW] = {
+ ...(nextOverrides[activeGW] || {}), ids: finalSquad.map(p => p.ID),
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
+ manualTransfers: { ...(nextOverrides[activeGW]?.manualTransfers || {}), [blankId]: playerToDrop }
+ };
+
+ const mapping = { [playerToDrop.ID]: blankId };
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
+
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
+ setHighlightTransferIds(clearFuture); setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null);
+ };
+
+ const handleAddPlayer = (newPlayer) => {
+ const cost = getPlayerPrice(newPlayer);
+ if (itb < cost) return alert("Insufficient funds!");
+
+ 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);
+ const opt = getValidLayout(newSquad, activeGW);
+ const finalSquad = opt ? opt.optimalArray : newSquad;
+
+ let nextTransfers = { ...transfersByGw };
+ nextTransfers[activeGW] = { ...(nextTransfers[activeGW] || { count: 0, netDelta: 0 }), count: (nextTransfers[activeGW]?.count || 0) + 1, netDelta: (nextTransfers[activeGW]?.netDelta || 0) - cost };
+
+ const newManualTransfers = { ...(manualOverrides[activeGW]?.manualTransfers || {}) };
+ delete newManualTransfers[selectedPlayer.ID];
+ if (selectedPlayer.replacedPlayer) newManualTransfers[newPlayer.ID] = selectedPlayer.replacedPlayer;
+
+ let nextOverrides = { ...manualOverrides };
+ nextOverrides[activeGW] = {
+ ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID),
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
+ forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers
+ };
+
+ const mapping = { [selectedPlayer.ID]: newPlayer.ID };
+ if (selectedPlayer.replacedPlayer) mapping[selectedPlayer.replacedPlayer.ID] = newPlayer.ID;
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
+
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
+ setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: [...(prev[activeGW] || []), newPlayer.ID] }));
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); setSearchQuery("");
+ };
+
+ const handleUndoTransfer = (e, currentId, replacedPlayer) => {
+ e.stopPropagation();
+ const buyPlayer = teamData.find((p) => String(p.ID) === String(currentId)) || globalPlayers.find((p) => String(p.ID) === String(currentId));
+ const buy = (!String(currentId).startsWith("blank_") && buyPlayer) ? getPlayerPrice(buyPlayer) : 0;
+ const sell = getPlayerPrice(replacedPlayer);
+
+ // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup
+ // const freshReplacedPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(replacedPlayer.ID)) || replacedPlayer), Price: getPlayerPrice(replacedPlayer) };
+ const freshReplacedPlayer = hydratePlayer(replacedPlayer.ID, replacedPlayer) || replacedPlayer;
+
+ const newSquad = teamData.map((p) => (String(p.ID) === String(currentId) ? freshReplacedPlayer : p));
+ const opt = getValidLayout(newSquad, activeGW);
+ const finalSquad = opt ? opt.optimalArray : newSquad;
+
+ let nextTransfers = { ...transfersByGw };
+ const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 };
+ nextTransfers[activeGW] = { ...row, count: Math.max(0, row.count - (!String(currentId).startsWith("blank_") ? 1 : 0)), netDelta: row.netDelta - (sell - buy) };
+
+ let nextOverrides = { ...manualOverrides };
+ const newManualTransfers = { ...(nextOverrides[activeGW]?.manualTransfers || {}) };
+ delete newManualTransfers[currentId];
+
+ nextOverrides[activeGW] = {
+ ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID),
+ cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId,
+ forcedZeros: nextOverrides[activeGW]?.forcedZeros || [], manualTransfers: newManualTransfers
+ };
+
+ const mapping = { [currentId]: replacedPlayer.ID };
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
+
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad); setSolverTransferPairs(cascadedP);
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
+ setHighlightTransferIds((prev) => clearFuture({ ...prev, [activeGW]: Array.from(prev[activeGW] || []).filter((id) => String(id) !== String(currentId)) }));
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null);
+ };
+
+ const resetHighlightedTransfer = (player) => {
+ const pair = (solverTransferPairs[activeGW] || {})[player.ID];
+ if (pair?.outPlayer) {
+ const idx = teamData.findIndex((p) => p.ID === player.ID);
+ if (idx < 0) return;
+
+ // FRESHEN REPLACED PLAYER: Ensure EV is up to date before optimizing the lineup
+ // const freshOutPlayer = { ...(globalPlayers.find(p => String(p.ID) === String(pair.outPlayer.ID)) || pair.outPlayer), Price: getPlayerPrice(pair.outPlayer) };
+ const freshOutPlayer = hydratePlayer(pair.outPlayer.ID, pair.outPlayer) || pair.outPlayer;
+ const newSquad = [...teamData]; newSquad[idx] = freshOutPlayer;
+
+ const buyPrice = getPlayerPrice(player); const sellPrice = getPlayerPrice(pair.outPlayer);
+
+ let nextTransfers = { ...transfersByGw };
+ const row = nextTransfers[activeGW] || { count: 0, netDelta: 0 };
+ nextTransfers[activeGW] = {
+ ...row, count: Math.max(0, row.count - 1), netDelta: row.netDelta - (sellPrice - buyPrice),
+ 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))
+ };
+
+ const opt = getValidLayout(newSquad, activeGW);
+ const finalSquad = opt ? opt.optimalArray : newSquad;
+
+ let nextOverrides = { ...manualOverrides };
+ nextOverrides[activeGW] = { ...nextOverrides[activeGW], ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: nextOverrides[activeGW]?.forcedZeros || [] };
+
+ const mapping = { [player.ID]: pair.outPlayer.ID };
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs, mapping);
+
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad);
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
+
+ const nP = { ...cascadedP };
+ if (nP[activeGW]) { delete nP[activeGW][player.ID]; }
+ setSolverTransferPairs(nP);
+
+ 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); });
+ setChipsByGw(clearFuture); setAppliedPlanSummary(null); setSelectedPlayer(null); return;
+ }
+ if (player.replacedPlayer) { handleUndoTransfer({ stopPropagation: () => { } }, player.ID, player.replacedPlayer); return; }
+ handleTransferOut(player);
+ };
+
+ const handleResetGWTransfers = () => {
+ let previousSquadIds = [];
+ const currentIndex = availableGWs.indexOf(activeGW);
+ if (currentIndex > 0) {
+ const prevGw = availableGWs[currentIndex - 1];
+ previousSquadIds = manualOverrides[prevGw]?.ids || initialSquadIds;
+ } else {
+ previousSquadIds = initialSquadIds;
+ }
+
+ //const restoredSquadUnsorted = previousSquadIds.map(id => {
+ // const p = globalPlayers.find(x => String(x.ID) === String(id));
+ // const existing = teamData.find(t => String(t.ID) === String(id));
+ // return existing ? { ...p, Price: existing.Price } : { ...p, Price: getPlayerPrice(p) };
+ // }).filter(Boolean);
+ const restoredSquadUnsorted = previousSquadIds.map(id => hydratePlayer(id)).filter(Boolean);
+
+ const opt = getValidLayout(restoredSquadUnsorted, activeGW);
+ const finalSquad = opt ? opt.optimalArray : restoredSquadUnsorted;
+
+ let nextTransfers = { ...transfersByGw };
+ delete nextTransfers[activeGW];
+
+ let nextOverrides = { ...manualOverrides };
+ nextOverrides[activeGW] = {
+ ids: finalSquad.map(p => p.ID), cap: opt ? opt.cap : captainId, vice: opt ? opt.vice : viceId, forcedZeros: [], manualTransfers: {}
+ };
+
+ const { nextOverrides: cascadedO, nextTransfers: cascadedT, nextPairs: cascadedP } = updateFutureTimelines(teamData, finalSquad, nextOverrides, nextTransfers, solverTransferPairs);
+
+ setTransfersByGw(cascadedT); setManualOverrides(cascadedO); setTeamData(finalSquad);
+ if (opt) { setCaptainId(opt.cap); setViceId(opt.vice); }
+
+ const nP = { ...cascadedP };
+ delete nP[activeGW];
+ setSolverTransferPairs(nP);
+
+ setHighlightTransferIds(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); });
+ setChipsByGw(prev => { const next = { ...prev }; delete next[activeGW]; return clearFuture(next); });
+ setSolverApplySnapshot(null); setAppliedPlanSummary(null);
+ };
+
+ // --- UI FIREWALL ---
+ // Forces the Pitch to instantly drop stale undo buttons during GW tab switches
+ const renderTeamData = useMemo(() => {
+ const lock = manualOverrides[activeGW];
+ return teamData.map(p => {
+ if (p.isBlank && String(p.ID).startsWith("blank_")) return p;
+ const cleanP = { ...p };
+ if (lock?.manualTransfers && lock.manualTransfers[p.ID]) {
+ cleanP.replacedPlayer = lock.manualTransfers[p.ID];
+ } else {
+ delete cleanP.replacedPlayer;
+ }
+ return cleanP;
+ });
+ }, [teamData, activeGW, manualOverrides]);
+
+ return (
+
+
+ {/* Minimal Top Bar for Load */}
+
+
+
+
+ {teamData.length > 0 ? (
+ <>
+
+ {/* Pitch Rendering wrapper */}
+
+
+
+
+ {/* STATS ROW - Clean spacing, non-wrapping to prevent jitter */}
+
+
+ ITB
+ £{Math.abs(itb) < 0.05 ? "0.0" : itb.toFixed(1)}m
+
+
+ FT
+
+ {(() => {
+ const chip = chipsByGw[activeGW]; const T = transfersByGw[activeGW]?.count ?? 0;
+ if (chip === "wc") return <>⚡ WC ({T}/∞) >;
+ if (chip === "fh") return <>↩ FH ({T}/∞) >;
+ return `${T} / ${ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw)}${hitsThisGw > 0 ? ` (-${hitsThisGw * 4} pts)` : ""}`;
+ })()}
+
+
+
+ Horizon
+ setHorizon(Number(e.target.value))} className="bg-transparent text-sm font-mono font-bold text-luigi-400 outline-none cursor-pointer">
+ {Array.from({ length: maxAvailableHorizon }, (_, i) => i + 1).map((h) => ({h} {h === 1 ? "GW" : "GWs"} ))}
+
+
+
+
+ GW {activeGW} EV
+ {activeGwEV.toFixed(2)}
+
+ {horizonGWs.length > 1 && (
+
+ Horizon EV
+ {horizonEV.toFixed(2)}
+
+ )}
+
+
+ {/* BUTTON ROW - Pushed to the right, strictly locked nowrap */}
+
+
+ {/* 1. RESET TRANSFERS/CHIPS BUTTON (Always Rendered, Disabled if Not Needed) */}
+ {(() => {
+ const canReset = (transfersByGw[activeGW]?.count > 0) || chipsByGw[activeGW] || (manualOverrides[activeGW]?.manualTransfers && Object.keys(manualOverrides[activeGW].manualTransfers).length > 0);
+ return (
+
+ Reset
+
+ );
+ })()}
+
+ {/* 2. RESET LINEUP BUTTON (Always Rendered, Disabled if Not Needed) */}
+ {(() => {
+ let canResetLineup = false;
+ const gwLock = manualOverrides[activeGW];
+
+ if (gwLock?.ids && teamData.length === 15 && !teamData.some((p) => p.isBlank && !String(p.ID).startsWith("blank_"))) {
+ const opt = getValidLayout(teamData, activeGW);
+ if (opt) {
+ const lockStarterSet = new Set(gwLock.ids.slice(0, 11));
+ const optStarterSet = new Set(opt.optimalArray.slice(0, 11).map((p) => p.ID));
+ const differentStarters = lockStarterSet.size !== optStarterSet.size || [...lockStarterSet].some((id) => !optStarterSet.has(id));
+ const meaningfulDiff = differentStarters || gwLock.cap !== opt.cap || gwLock.vice !== opt.vice;
+
+ if (meaningfulDiff) {
+ const getPts = (p) => Number(p[`${activeGW}_Pts`]) || 0;
+ const currentEV = teamData.slice(0, 11).reduce((sum, p) => sum + getPts(p) * (p.ID === gwLock.cap ? 2 : 1), 0);
+ const optEV = opt.optimalArray.slice(0, 11).reduce((sum, p) => sum + getPts(p) * (p.ID === opt.cap ? 2 : 1), 0);
+
+ if (optEV > currentEV + 0.01) {
+ canResetLineup = true;
+ }
+ }
+ }
+ }
+
+ return (
+
+ Reset Lineup
+
+ );
+ })()}
+
+ {/* 3. CHIP DROPDOWN */}
+
+ Chip:
+ 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">
+ None ⚡ WC ↩ FH ⬆ BB ✕3 TC
+
+
+
+
+
+
+
+
+ {/* MULTIVERSE TIMELINE CONTROL BAR */}
+
+
+ {/* LEFT: Custom Editable Dropdown Box */}
+
+
+ d.id === activeDraftId)?.name || ""}
+ onChange={(e) => setDrafts(prev => prev.map(d => d.id === activeDraftId ? { ...d, name: e.target.value } : d))}
+ className="w-full bg-transparent text-indigo-100 font-bold text-[11px] py-1 px-3 outline-none placeholder:text-slate-600"
+ placeholder="Draft Name..."
+ />
+ setShowDraftMenu(!showDraftMenu)}
+ className="px-2.5 h-full flex items-center justify-center bg-[#151833] border-l border-[#2a2d5c] hover:bg-[#1e2247] transition-colors"
+ >
+ ▼
+
+
+
+ {showDraftMenu && (
+ <>
+
setShowDraftMenu(false)} />
+
+ {drafts.map(d => (
+ { setActiveDraftId(d.id); setShowDraftMenu(false); }}
+ 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"}`}
+ >
+ {d.name}
+
+ ))}
+
+ >
+ )}
+
+
+ {/* CENTER: Gameweek Circles */}
+
+ {horizonGWs.map((gw) => (
+ 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"}`}>
+ {gw}
+ {chipsByGw[gw] && ({CHIP_CONFIG[chipsByGw[gw]].short[0]} )}
+
+ ))}
+
+
+ {/* RIGHT: Clone / New Draft / Delete */}
+ {/* RIGHT: Clone / New Draft */}
+
+
= 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">
+ Clone
+
+
= 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">
+ New
+
+
+
+
+
+
+
+ {activeDragPlayer && !activeDragPlayer.isBlank ? (
+
+ ) : null}
+
+
+ >
+ ) : (
+
+ {isLoadingDB || isLoading ? (
+ <>
+
+ {[1, 4, 4, 2].map((count, rowIdx) => (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ ))}
+
+
+
{isLoading ? "Loading squad..." : "Booting Global Engine..."}
+ >
+ ) : (
+ "Enter your FPL ID above to load your squad."
+ )}
+
+ )}
+
+
+ {/* Right Column */}
+
+
+ {/* NEW HOME FOR TABS PANEL */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* MODALS */}
+ {selectedPlayer && !selectedPlayer.isBlank && (
+
+ )}
+ {selectedPlayer && selectedPlayer.isBlank && (
+
+ )}
+ {showAdvancedSettings && (
+
+ )}
+
+ {/* LOADING PORTALS */}
+ {isSolving && createPortal(
+
+
+
+
+
+
+ {/* BRANDED LOGO */}
+
+
+
+
Solving
+
Elapsed {solveElapsedSec}s · up to {quickSettings.iterations} iteration(s)
+
+
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
+
+
, document.body
+ )}
+
+ {isChipSolving && createPortal(
+
+
+
+
+
+
+ {/* BRANDED LOGO */}
+
+
+
+
Chip Solving
+
Elapsed {chipSolveTimer}s
+
+
+
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
+
+
, document.body
+ )}
+
+ {isRunningSens && createPortal(
+
+
+
+
+
+
+ {/* BRANDED LOGO */}
+
+
+
+
Sensitivity Analysis
+
Elapsed {sensTimer}s · {numSims} sims running…
+
+
+
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
+
+
, document.body
+ )}
+
+ {showIdPrompt && (
+
+
+
+
Save as Default ID?
+
+ setShowIdPrompt(false)} className="flex-1 bg-slate-900 text-slate-300 py-2.5 rounded-xl border border-slate-700">Not Now
+ { 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
+
+
+
+ )}
+ {/* INITIAL LOGIN ID PROMPT */}
+ {showInitialIdPrompt && (
+
+
+
+
Welcome!
+
Enter your FPL Team ID to set it as your default for future logins.
+
setInitialIdInput(e.target.value)}
+ placeholder="e.g. 123456"
+ 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"
+ />
+
+ 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
+ Save Default ID
+
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/SolverOutputPanel.jsx b/frontend/src/components/SolverOutputPanel.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..7586aff320fa3d2316314779fb1ba45925bcbefc
--- /dev/null
+++ b/frontend/src/components/SolverOutputPanel.jsx
@@ -0,0 +1,201 @@
+import React from "react";
+import { Zap, ExternalLink } from "lucide-react";
+import { CHIP_CONFIG } from "../utils/fplLogic";
+
+export const SolverOutputPanel = ({
+ pendingSolutions, setPendingSolutions, isSolving, globalPlayers, applySolution, appliedPlanSummary, setAppliedPlanSummary, baselineEv = 0
+}) => {
+
+ // BULLETPROOF RELATIVE EV
+ const getRelativeEv = (sol) => {
+ if (baselineEv === undefined || !sol) return "+0.00";
+
+ const base = sol.lockedBaselineEv !== undefined ? sol.lockedBaselineEv : baselineEv;
+
+ if (typeof sol === "number") {
+ const diff = sol - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ if (!sol.plan || !Array.isArray(sol.plan) || sol.plan.length === 0) {
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
+ const diff = fallbackEv - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ let pathEV = 0;
+ let hasValidGw = false;
+
+ sol.plan.forEach(gwPlan => {
+ const gw = gwPlan.gw;
+ if (gw === undefined) return;
+ hasValidGw = true;
+
+ const gwChip = gwPlan.chip;
+ const gwCapMult = gwChip === "tc" ? 3 : 2;
+ let gwPts = 0;
+
+ const getPlayer = (id) => globalPlayers.find(p => String(p.ID) === String(id));
+
+ (gwPlan.lineup || []).forEach(id => {
+ const p = getPlayer(id);
+ if (p && !p.isBlank) {
+ const pts = Number(p[`${gw}_Pts`]) || 0;
+ gwPts += pts * (String(p.ID) === String(gwPlan.captain) ? gwCapMult : 1);
+ }
+ });
+
+ let ofIdx = 0;
+ (gwPlan.bench || []).forEach(id => {
+ const p = getPlayer(id);
+ if (p && !p.isBlank) {
+ const pts = Number(p[`${gw}_Pts`]) || 0;
+ if (gwChip === "bb") {
+ gwPts += pts;
+ } else if (p.Pos === "G") {
+ gwPts += pts * 0.04;
+ } else {
+ gwPts += pts * ([0.17, 0.05, 0.02][ofIdx] || 0.02);
+ ofIdx++;
+ }
+ }
+ });
+ pathEV += gwPts - (gwPlan.hits || 0) * 4;
+ });
+
+ if (!hasValidGw || Number.isNaN(pathEV)) {
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
+ const diff = fallbackEv - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ const diff = pathEV - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ };
+
+ return (
+
+
+
+ {/* Left Side: Original Title & Description */}
+
+
Solver output
+
Nothing changes your squad until you apply a path.
+
+
+ {/* Right Side: Sleek, Minimalist Credit */}
+ {/* Right Side: Sleek, Minimalist Credit */}
+
+ Credit
+ Sertalp-Moose Solver
+
+
+
+
+
+
+ {pendingSolutions.length > 0 && !isSolving && (
+
+
+
Optimal Paths Found
+ setPendingSolutions([])} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase transition-colors">Clear
+
+
+ {pendingSolutions.map((sol, index) => (
+
+
+
+ ITERATION {sol.id || index + 1}
+ {sol.chips_used && Object.entries(sol.chips_used).map(([gw, chip]) => {
+ const cfg = CHIP_CONFIG[chip];
+ return cfg ? {cfg.short}{gw} : null;
+ })}
+
+
+ {getRelativeEv(sol)} pts
+ {sol.objective_score != null && eval: {sol.objective_score.toFixed(2)} }
+
+
+
+
+ {sol.plan.map((gwPlan) => (
+ (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) && (
+
+
+
+ GW {gwPlan.gw}
+ {gwPlan.chip && CHIP_CONFIG[gwPlan.chip] && {CHIP_CONFIG[gwPlan.chip].short}{gwPlan.gw} }
+
+
+ ITB: £{Math.abs(gwPlan.itb) < 0.05 ? "0.0" : Number(gwPlan.itb).toFixed(1)}
+ 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})` : ""}`}
+
+
+ {gwPlan.chip === "wc" || gwPlan.chip === "fh" ?
{gwPlan.chip === "wc" ? "Wildcard active — unlimited free transfers" : "Free Hit active — squad reverts after the FH"}
: null}
+ {gwPlan.transfers_out.map((id, i) => (
+
+ {globalPlayers.find((p) => String(p.ID) === String(id))?.Name || id}
+ »
+ {globalPlayers.find((p) => String(p.ID) === String(gwPlan.transfers_in[i]))?.Name || gwPlan.transfers_in[i]}
+
+ ))}
+
+ )
+ ))}
+
+
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
+
+ ))}
+
+ )}
+
+ {!isSolving && pendingSolutions.length === 0 && (
+ appliedPlanSummary ? (
+
+
+
Last Applied · {appliedPlanSummary.horizon}
+ setAppliedPlanSummary(null)} className="text-slate-600 hover:text-red-400 text-xs font-bold">✕
+
+
{getRelativeEv(appliedPlanSummary)} pts {appliedPlanSummary.objectiveScore != null && ` · eval ${appliedPlanSummary.objectiveScore.toFixed(2)}`}
+ {appliedPlanSummary.transfers.map((t, i) => (
+
+
+
+ GW {t.gw}
+ {t.chip && CHIP_CONFIG[t.chip] && {CHIP_CONFIG[t.chip].short}{t.gw} }
+
+
+ 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})` : ""}`}
+ £{Math.abs(t.itb) < 0.05 ? "0.0" : Number(t.itb).toFixed(1)}m
+
+
+ {t.outs.length === 0 && t.ins.length === 0 ? (
+
{t.chip ? `${CHIP_CONFIG[t.chip]?.label || t.chip} active` : 'Hold — no transfers'}
+ ) : (
+ t.outs.map((name, j) => (
+
+ {name}
+ »
+ {t.ins[j] || "?"}
+
+ ))
+ )}
+
+ ))}
+
+ ) : (
+
+
+ Configure settings and hit Solve in the left panel.
+
+ )
+ )}
+
+
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/SquadEvDisplay.jsx b/frontend/src/components/SquadEvDisplay.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..d7ce8d0b82e75ab2e64eda9bdf6f25cf5ee96477
--- /dev/null
+++ b/frontend/src/components/SquadEvDisplay.jsx
@@ -0,0 +1,16 @@
+import React from "react";
+
+export const SquadEvDisplay = ({ activeGW, activeGwEV, horizonGWs, horizonEV }) => {
+ return (
+ <>
+
+ GW {activeGW} EV: {activeGwEV.toFixed(2)}
+
+ {horizonGWs?.length > 1 && (
+
+ GW {horizonGWs[0]}-{horizonGWs[horizonGWs.length - 1]} EV: {horizonEV.toFixed(2)}
+
+ )}
+ >
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/TabsPanel.jsx b/frontend/src/components/TabsPanel.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..ac71da89c74ecbe61985e5cfada2741ee800410b
--- /dev/null
+++ b/frontend/src/components/TabsPanel.jsx
@@ -0,0 +1,347 @@
+import React, { useContext } from "react";
+import { Settings } from "lucide-react";
+import { CHIP_CONFIG } from "../utils/fplLogic";
+import { PlayerContext } from "../PlayerContext";
+import { FixtureMatrixPanel } from "./FixtureMatrixPanel";
+
+export const TabsPanel = ({
+ solverTab, setSolverTab,
+ isSolving, isRunningSens, isChipSolving,
+ runMainSolver, runSensAnalysis, runChipSolve,
+ setShowAdvancedSettings,
+ quickSettings, setQuickSettings,
+ banSearch, setBanSearch, lockSearch, setLockSearch,
+ globalPlayers, teamData, solveGWLabel,
+ numSims, setNumSims, sensResults, setSensResults, sensViewGw, setSensViewGw,
+ chipSolveOptions, setChipSolveOptions, chipSolveSolutions, setChipSolveSolutions, horizonGWs,baselineEv = 0
+}) => {
+
+ const { fixtureOverrides, setFixtureOverrides, availableGWs } = useContext(PlayerContext);
+
+ const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : "";
+
+ const getRelativeEv = (sol) => {
+ if (baselineEv === undefined || !sol) return "+0.00";
+
+ // Prevent 0.0 bug: Use locked baseline if it's the "Last Applied" summary
+ const base = sol.lockedBaselineEv !== undefined ? sol.lockedBaselineEv : baselineEv;
+
+ // Prevent NaN bug: Safely handle if sol is just a raw number by accident
+ if (typeof sol === "number") {
+ const diff = sol - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ // Prevent NaN bug: If the API didn't return a full detailed plan (like in Chip Solve)
+ if (!sol.plan || !Array.isArray(sol.plan) || sol.plan.length === 0) {
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
+ const diff = fallbackEv - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ let pathEV = 0;
+ let hasValidGw = false;
+
+ sol.plan.forEach(gwPlan => {
+ const gw = gwPlan.gw;
+ if (gw === undefined) return; // Prevents checking p['undefined_Pts']
+ hasValidGw = true;
+
+ const gwChip = gwPlan.chip;
+ const gwCapMult = gwChip === "tc" ? 3 : 2;
+ let gwPts = 0;
+
+ const getPlayer = (id) => globalPlayers.find(p => String(p.ID) === String(id));
+
+ (gwPlan.lineup || []).forEach(id => {
+ const p = getPlayer(id);
+ if (p && !p.isBlank) {
+ const pts = Number(p[`${gw}_Pts`]) || 0;
+ gwPts += pts * (String(p.ID) === String(gwPlan.captain) ? gwCapMult : 1);
+ }
+ });
+
+ let ofIdx = 0;
+ (gwPlan.bench || []).forEach(id => {
+ const p = getPlayer(id);
+ if (p && !p.isBlank) {
+ const pts = Number(p[`${gw}_Pts`]) || 0;
+ if (gwChip === "bb") {
+ gwPts += pts;
+ } else if (p.Pos === "G") {
+ gwPts += pts * 0.04;
+ } else {
+ gwPts += pts * ([0.17, 0.05, 0.02][ofIdx] || 0.02);
+ ofIdx++;
+ }
+ }
+ });
+ pathEV += gwPts - (gwPlan.hits || 0) * 4;
+ });
+
+ // Failsafe in case the plan existed but couldn't be parsed properly
+ if (!hasValidGw || Number.isNaN(pathEV)) {
+ const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
+ const diff = fallbackEv - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ }
+
+ const diff = pathEV - base;
+ return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
+ };
+
+ return (
+ <>
+
setShowAdvancedSettings(true)} className="absolute top-3 right-3 text-slate-500 hover:text-luigi-400 transition-colors z-10" aria-label="Comprehensive settings">
+
+ {["solver", "sensitivity", "chips","fixtures"].map((tab) => (
+ setSolverTab(tab)} className={`flex-1 py-4 text-xs font-black uppercase tracking-widest transition-colors ${solverTab === tab ? "text-luigi-400 border-b-2 border-luigi-400" : "text-slate-500 hover:text-slate-300"}`}>
+ {tab === "solver" ? "Solve" : tab === "sensitivity" ? "Sens Anal" : tab === "chips" ? "Chips" : "Fixtures"}
+
+ ))}
+
+
+
+ {solverTab === "solver" && (
+
+
Quick Settings
+
+ {/* TOOLBAR LAYOUT: Readable sizes, allowed to wrap if screen is small */}
+
+
+ {/* Decay */}
+
+ Decay
+ setQuickSettings({ ...quickSettings, decay: e.target.value }) } className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500" />
+
+
+ {/* FT Val */}
+
+ FT Val
+ setQuickSettings({ ...quickSettings, ft_value: Number(e.target.value) }) } className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500" />
+
+
+ {/* Iters */}
+
+ Iters
+ setQuickSettings({...quickSettings, iterations: Number(e.target.value)})} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500 cursor-pointer">
+ {[1, 2, 3, 4, 5].map(i => {i} )}
+
+
+
+ {/* Lock */}
+
+
Lock
+
setLockSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-emerald-400 focus:outline-none focus:border-emerald-500" />
+ {lockSearch && (
+
+ {/* THE FIX: Added (quickSettings.locked || []) */}
+ {globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(lockSearch)) && !(quickSettings.locked || []).some((l) => l.ID === p.ID)).slice(0, 10).map((p) => (
+
{ setQuickSettings((prev) => ({ ...prev, locked: [...(prev.locked || []), p] })); setLockSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
+ {p.Name} ({p.Team})
+
+ ))}
+
+ )}
+
+ {/* THE FIX: Added (quickSettings.locked || []) */}
+ {(quickSettings.locked || []).map((p) => (
+
+ {p.Name} setQuickSettings((prev) => ({ ...prev, locked: (prev.locked || []).filter((l) => l.ID !== p.ID) })) } className="hover:text-white shrink-0">✕
+
+ ))}
+
+
+
+ {/* Ban */}
+
+
Ban
+
setBanSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-red-400 focus:outline-none focus:border-red-500" />
+ {banSearch && (
+
+ {/* THE FIX: Added (quickSettings.banned || []) */}
+ {globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(banSearch)) && !(quickSettings.banned || []).some((b) => b.ID === p.ID)).slice(0, 10).map((p) => (
+
{ setQuickSettings((prev) => ({ ...prev, banned: [...(prev.banned || []), p] })); setBanSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
+ {p.Name} ({p.Team})
+
+ ))}
+
+ )}
+
+ {/* THE FIX: Added (quickSettings.banned || []) */}
+ {(quickSettings.banned || []).map((p) => (
+
+ {p.Name} setQuickSettings((prev) => ({ ...prev, banned: (prev.banned || []).filter((b) => b.ID !== p.ID) })) } className="hover:text-white shrink-0">✕
+
+ ))}
+
+
+
+
+
+
p.isBlank) || teamData.length === 0 || isSolving } className="mt-auto self-center bg-luigi-500 hover:bg-luigi-400 text-slate-950 font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-xs uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none">
+ Solve {solveGWLabel ? `(${solveGWLabel})` : ""}
+
+
+ )}
+
+ {solverTab === "sensitivity" && (
+
+
+ Runs N randomised solves with per-player EV noise.
+
+
+ Simulations
+ setNumSims(Math.max(5, Math.min(200, Number(e.target.value)))) } className="w-20 bg-slate-900 border border-slate-700 rounded p-2 text-slate-200 font-mono text-sm outline-none focus:border-luigi-500" />
+
+
p.isBlank) || teamData.length === 0 } className="self-center bg-cyan-700 hover:bg-cyan-600 text-white font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-sm uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed">
+ {isRunningSens ? `Running...` : `Run Sims ${solveGWLabel ? `(${solveGWLabel})` : ""}`}
+
+ {sensResults && (
+
+
+
{sensResults.valid_runs}/{sensResults.num_sims} valid
+ { setSensResults(null); setSensViewGw(null); }} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase">Clear
+
+
+ {(sensResults.horizon_gws || []).map((gw) => {
+ const isChipFree = sensResults.gw_results?.[String(gw)]?.is_chip_free;
+ const isActive = sensViewGw === gw;
+ return (
+ setSensViewGw(gw)} className={`w-8 h-8 rounded-full text-[11px] font-black transition-colors ${isActive ? isChipFree ? "bg-purple-500 text-white" : "bg-cyan-500 text-slate-950" : isChipFree ? "bg-purple-900/60 text-purple-300 hover:bg-purple-800/60 ring-1 ring-purple-500/40" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`}>{gw}
+ );
+ })}
+
+ {sensViewGw && sensResults.gw_results?.[String(sensViewGw)] && (() => {
+ const gd = sensResults.gw_results[String(sensViewGw)];
+ if (gd.is_chip_free) {
+ const POS_NAMES = { G: "Goalkeepers", D: "Defenders", M: "Midfielders", F: "Forwards" };
+ return (
+
+
⚡ Wildcard / Free Hit — Squad Selection
+ {["G", "D", "M", "F"].map((pos) => {
+ const rows = gd.players?.[pos] || [];
+ if (!rows.length) return null;
+ return (
+
+
{POS_NAMES[pos]}
+
Player Squad Lineup
+ {rows.map((r, i) => (
+
+
+
{r.squad_pct}% {r.lineup_pct}%
+
+ ))}
+
+ );
+ })}
+
+ );
+ }
+ return (
+
+ {gd.no_transfer_pct > 0 &&
Hold (no transfer): {gd.no_transfer_pct}% of sims
}
+ {["moves", "buys", "sells"].map((key) => {
+ const rows = gd[key] || [];
+ if (!rows.length) return null;
+ const titles = { moves: "Moves (Out → In)", buys: "Buys", sells: "Sells" };
+ return (
+
+
{titles[key]}
+ {rows.slice(0, 10).map((r, i) => (
+
+ ))}
+
+ );
+ })}
+
+ );
+ })()}
+
+ )}
+
+ )}
+
+ {solverTab === "chips" && (
+
+
+ Select which GWs each chip can be played in, then hit Chip Solve .
+
+
+ {["wc", "fh", "bb", "tc"].map((key) => {
+ const cfg = CHIP_CONFIG[key];
+ const sel = chipSolveOptions[key] || [];
+ return (
+
+
+ {cfg.label}
+ {sel.length > 0 && {sel.length} GW{sel.length > 1 ? "s" : ""} }
+
+
+ {horizonGWs.map((gw) => {
+ const active = sel.includes(gw);
+ return (
+ setChipSolveOptions((prev) => ({ ...prev, [key]: active ? prev[key].filter((g) => g !== gw) : [...prev[key], gw] })) } className={`text-[10px] px-1.5 py-0.5 rounded font-bold transition-colors ${active ? cfg.activeBg : cfg.inactiveBg}`}>{gw}
+ );
+ })}
+
+
+ );
+ })}
+
+
p.isBlank) || teamData.length === 0 || Object.values(chipSolveOptions).every((v) => v.length === 0) } className="self-center mt-2 bg-purple-600 hover:bg-purple-500 text-white font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-sm uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed">
+ {isChipSolving ? `Solving...` : "Chip Solve"}
+
+ {chipSolveSolutions.length > 0 && (
+
+
+
Best Chip Combos
+ setChipSolveSolutions([])} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase">Clear
+
+ {chipSolveSolutions.slice(0, 8).map((sol, i) => {
+ const combo = sol.chip_combo || {};
+
+ const active = Object.entries(combo)
+ .filter(([, gws]) => Array.isArray(gws) && gws.length > 0)
+ .map(([k, gws]) => `${k.replace("use_", "").toUpperCase()}${gws[0]}`);
+
+ return (
+
+
+ {/* 1. Added Rank Number (1., 2., 3...) */}
+ {i + 1}.
+ {active.length ? active.join(" + ") : "No chips"}
+
+
+
+
+ {sol.objective_score != null ? sol.objective_score.toFixed(2) : sol.ev.toFixed(2)}
+
+ {/* 2. EV is now bigger (text-[10px]) and colored purple to match! */}
+
+ ({getRelativeEv(sol)} ev)
+
+
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {solverTab === "fixtures" && (
+
+ )}
+
+ >
+ );
+};
\ No newline at end of file
diff --git a/frontend/src/components/TeamRatings.jsx b/frontend/src/components/TeamRatings.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..2881f907bd3ebaf10bd36e7d130799aa0649c3cf
--- /dev/null
+++ b/frontend/src/components/TeamRatings.jsx
@@ -0,0 +1,130 @@
+import React, { useState, useEffect } from 'react';
+
+export default function TeamRatings() {
+ const [data, setData] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ useEffect(() => {
+ fetch('https://anayshukla-fpl-solver.hf.space/api/ratings')
+ .then(res => res.json())
+ .then(jsonData => {
+ setData(jsonData.sort((a, b) => b.Diff - a.Diff));
+ setIsLoading(false);
+ });
+ }, []);
+
+ if (isLoading) return
Loading ratings...
;
+
+ const minAttack = 0.6, maxAttack = 2.2;
+ const minDef = 0.6, maxDef = 2.0;
+
+ const yTicks = [0.6, 0.8, 1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2];
+ const xTicks = [2.0, 1.8, 1.6, 1.4, 1.2, 1.0, 0.8, 0.6];
+
+ return (
+
+
+ {/* WIDESCREEN SCATTER PLOT */}
+
+
Team Strengths
+
+
+
+ {/* THE FIX: Added ml-16 and mb-16 to give the absolute-positioned numbers room to breathe without being clipped! */}
+
+
+ {/* Y-Axis Label (Attack) - Swapped to ➡️ so the -90deg rotation makes it point UP */}
+
+ Attack (Better ➡️)
+
+
+ {/* X-Axis Label (Defence) - Now perfectly aligned */}
+
+ Defence (Better ➡️)
+
+
+ {/* Y-Axis Ticks */}
+ {yTicks.map(tick => {
+ const bottomPct = ((tick - minAttack) / (maxAttack - minAttack)) * 100;
+ return (
+
+
+
+ {tick.toFixed(1)}
+
+
+ );
+ })}
+
+ {/* X-Axis Ticks */}
+ {xTicks.map(tick => {
+ const leftPct = ((maxDef - tick) / (maxDef - minDef)) * 100;
+ return (
+
+
+
+ {tick.toFixed(1)}
+
+
+ );
+ })}
+
+ {/* Plotting the Teams */}
+ {data.map(team => {
+ const leftPct = ((maxDef - team.Defence) / (maxDef - minDef)) * 100;
+ const bottomPct = ((team.Attack - minAttack) / (maxAttack - minAttack)) * 100;
+
+ return (
+
+ {team.Logo ? (
+
+ ) : (
+
+ )}
+
+
+
{team.Team}
+
Attack: {team.Attack.toFixed(2)}
+
Defence: {team.Defence.toFixed(2)}
+
+
+ );
+ })}
+
+
+
+
+ {/* FULL WIDTH DATA TABLE */}
+
+
+
+
+ Team
+ Attack
+ Defence
+ Diff
+
+
+
+ {data.map(team => (
+
+ {team.Team}
+ {team.Attack.toFixed(2)}
+ {team.Defence.toFixed(2)}
+
+ 0 ? 'text-emerald-400' : 'text-red-400'}>
+ {team.Diff > 0 ? '+' : ''}{team.Diff.toFixed(2)}
+
+
+
+ ))}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/hooks/useFplSolverApi.js b/frontend/src/hooks/useFplSolverApi.js
new file mode 100644
index 0000000000000000000000000000000000000000..62dad1ec45e8caf4c1c23e78531ede4a74f25e8b
--- /dev/null
+++ b/frontend/src/hooks/useFplSolverApi.js
@@ -0,0 +1,272 @@
+import { useState } from "react";
+import { getPlayerPrice } from "../utils/fplLogic";
+
+export const useFplSolverApi = (abortControllerRef) => {
+ const [isSolving, setIsSolving] = useState(false);
+ const [isChipSolving, setIsChipSolving] = useState(false);
+ const [isRunningSens, setIsRunningSens] = useState(false);
+ const [pendingSolutions, setPendingSolutions] = useState([]);
+ const [chipSolveSolutions, setChipSolveSolutions] = useState([]);
+ const [sensResults, setSensResults] = useState(null);
+ const [sensViewGw, setSensViewGw] = useState(null);
+
+ // Reusable helper to format players for the backend
+ const formatMarketPlayers = (globalPlayers, teamData, horizonGWs) => {
+ return globalPlayers.map((p) => {
+ const squadPlayer = teamData.find((sp) => sp.ID === p.ID);
+ const sellPrice = squadPlayer ? squadPlayer.Price : getPlayerPrice(p);
+ const evs = {};
+ horizonGWs.forEach((gw) => {
+ evs[String(gw)] = Number(p[`${gw}_Pts`]) || 0;
+ });
+ return {
+ id: p.ID,
+ name: p.Name,
+ pos: p.Pos,
+ team: p.Team,
+ now_cost: getPlayerPrice(p),
+ sell_price: sellPrice,
+ evs,
+ };
+ });
+ };
+
+ // Extracts the basic frontend settings (bans, locks, chips) into a clean dictionary
+ const buildBaseSettings = (quickSettings, chipsByGw, forceIterations = null) => {
+ return {
+ decay_base: Number(quickSettings?.decay || 0.85),
+ ft_value_base: Number(quickSettings?.ft_value || 1.5),
+ iterations: forceIterations !== null ? forceIterations : Number(quickSettings?.iterations || 1),
+ banned_ids: quickSettings?.banned?.map((p) => p.ID) || [],
+ locked_ids: quickSettings?.locked?.map((p) => p.ID) || [],
+ use_wc: Object.entries(chipsByGw || {}).filter(([, c]) => c === "wc").map(([g]) => Number(g)),
+ use_fh: Object.entries(chipsByGw || {}).filter(([, c]) => c === "fh").map(([g]) => Number(g)),
+ use_bb: Object.entries(chipsByGw || {}).filter(([, c]) => c === "bb").map(([g]) => Number(g)),
+ use_tc: Object.entries(chipsByGw || {}).filter(([, c]) => c === "tc").map(([g]) => Number(g)),
+ };
+ };
+
+ const handleSolve = async ({
+ teamId, solveGWs, horizonGWs, teamData, globalPlayers, itb, availableFts,
+ quickSettings, chipsByGw, comprehensiveSettings,
+ lockedBaselineEv, pastBaselineEv // <-- ADDED HERE
+ }) => {
+ setIsSolving(true);
+ abortControllerRef.current = new AbortController();
+ try {
+ const payload = {
+ team_id: parseInt(teamId, 10) || 0,
+ horizon_gws: solveGWs,
+ current_squad_ids: teamData.filter((p) => !p.isBlank && typeof p.ID === "number").map((p) => p.ID),
+ market_players: formatMarketPlayers(globalPlayers, teamData, horizonGWs),
+ in_the_bank: itb,
+ free_transfers: availableFts,
+ settings: buildBaseSettings(quickSettings, chipsByGw),
+ comprehensive_settings: comprehensiveSettings,
+ };
+
+ const res = await fetch("https://anayshukla-fpl-solver.hf.space/api/solve", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: abortControllerRef.current.signal,
+ });
+
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.detail || data.message || "Solver failed");
+
+ if (data.status === "success" && Array.isArray(data.solutions)) {
+ // THE FIX: Inject the specific baselines and calculate the padded visual total
+ const enhancedSolutions = data.solutions.map(sol => ({
+ ...sol,
+ lockedBaselineEv,
+ paddedTotalEv: (sol.ev || 0) + (pastBaselineEv || 0)
+ }));
+
+ const sorted = [...enhancedSolutions].sort((a, b) => {
+ const oa = a.objective_score != null ? a.objective_score : a.ev;
+ const ob = b.objective_score != null ? b.objective_score : b.ev;
+ if (ob !== oa) return ob - oa;
+ return (b.ev || 0) - (a.ev || 0);
+ });
+ setPendingSolutions(sorted);
+ } else {
+ alert("Solver failed: " + (data.message || data.detail || "Unknown"));
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ alert(err.message || "Failed to run the solver.");
+ } finally {
+ setIsSolving(false);
+ }
+ };
+
+ const handleChipSolve = async ({
+ teamId, horizonGWs, teamData, globalPlayers, itb, availableFts,
+ quickSettings, comprehensiveSettings, chipSolveOptions,
+ lockedBaselineEv, pastBaselineEv // <-- ADDED HERE
+ }) => {
+ const hasOptions = Object.values(chipSolveOptions).some((v) => v.length > 0);
+ if (!hasOptions) {
+ alert("Select at least one GW for a chip before running the chip solve.");
+ return;
+ }
+
+ setIsChipSolving(true);
+ setChipSolveSolutions([]);
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const payload = {
+ team_id: parseInt(teamId, 10) || 0,
+ horizon_gws: horizonGWs,
+ current_squad_ids: teamData.filter((p) => !p.isBlank && typeof p.ID === "number").map((p) => p.ID),
+ market_players: formatMarketPlayers(globalPlayers, teamData, horizonGWs),
+ in_the_bank: itb,
+ free_transfers: availableFts,
+ settings: buildBaseSettings(quickSettings, {}),
+ comprehensive_settings: comprehensiveSettings,
+ chip_gw_options: {
+ wc: chipSolveOptions.wc,
+ fh: chipSolveOptions.fh,
+ bb: chipSolveOptions.bb,
+ tc: chipSolveOptions.tc,
+ },
+ };
+
+ const res = await fetch("https://anayshukla-fpl-solver.hf.space/api/chip-solve", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: abortControllerRef.current.signal,
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ const d = data.detail;
+ const msg = typeof d === "string" ? d : Array.isArray(d) ? d.map((x) => x.msg || x).join(", ") : JSON.stringify(d);
+ throw new Error(msg || "Chip solve failed");
+ }
+
+ if (data.status === "success" && Array.isArray(data.solutions)) {
+ // THE FIX: Inject the specific baselines
+ const enhancedSolutions = data.solutions.map(sol => ({
+ ...sol,
+ lockedBaselineEv,
+ paddedTotalEv: (sol.ev || 0) + (pastBaselineEv || 0)
+ }));
+ setChipSolveSolutions(enhancedSolutions);
+ } else {
+ alert("Chip solve failed: " + (data.message || "Unknown error"));
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ alert(err.message || "Failed to run chip solve.");
+ } finally {
+ setIsChipSolving(false);
+ }
+ };
+
+ const handleSensAnalysis = async ({
+ teamId, solveGWs, horizonGWs, teamData, globalPlayers, itb, availableFts,
+ quickSettings, chipsByGw, comprehensiveSettings, numSims, lockedBaselineEv, pastBaselineEv
+ }) => {
+ setIsRunningSens(true);
+ setSensResults(null);
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const payload = {
+ team_id: parseInt(teamId, 10) || 0,
+ horizon_gws: solveGWs,
+ current_squad_ids: teamData.filter((p) => !p.isBlank && typeof p.ID === "number").map((p) => p.ID),
+ market_players: formatMarketPlayers(globalPlayers, teamData, horizonGWs),
+ in_the_bank: itb,
+ free_transfers: availableFts,
+ settings: buildBaseSettings(quickSettings, chipsByGw, 1), // Force 1 iteration for sens analysis
+ comprehensive_settings: comprehensiveSettings,
+ num_sims: numSims,
+ analysis_gw: solveGWs[0] || null,
+ };
+
+ const res = await fetch("https://anayshukla-fpl-solver.hf.space/api/sensitivity", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ signal: abortControllerRef.current.signal,
+ });
+
+ const data = await res.json();
+ if (!res.ok) {
+ const d = data.detail;
+ const msg = typeof d === "string" ? d : Array.isArray(d) ? d.map((x) => x.msg || x).join(", ") : JSON.stringify(d);
+ throw new Error(msg || "Sensitivity analysis failed");
+ }
+
+ if (data.status === "success") {
+ setSensResults(data);
+ setSensViewGw(data.horizon_gws?.[0] ?? null);
+ } else {
+ alert("Sensitivity failed: " + (data.message || "Unknown error"));
+ }
+ } catch (err) {
+ if (err.name === 'AbortError') return;
+ alert(err.message || "Failed to run sensitivity analysis.");
+ } finally {
+ setIsRunningSens(false);
+ }
+ };
+
+ const loadSettingsFromCloud = async (teamId) => {
+ try {
+ const res = await fetch(`https://anayshukla-fpl-solver.hf.space/api/settings/${teamId}`);
+ const data = await res.json();
+ if (data.status === "success") {
+ console.log("📥 LOADED FROM CLOUD:", data);
+ return {
+ quick: data.quick_settings,
+ advanced: data.advanced_settings
+ };
+ }
+ } catch (err) {
+ console.warn("Failed to load cloud settings:", err);
+ }
+ return null;
+ };
+
+ const saveSettingsToCloud = async (teamId, quickSettings, compSettings) => {
+ console.log("📤 SAVING TO CLOUD. Advanced Settings payload:", compSettings);
+ try {
+ await fetch("https://anayshukla-fpl-solver.hf.space/api/settings/save", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ team_id: parseInt(teamId, 10),
+ quick_settings: quickSettings,
+ advanced_settings: compSettings // <-- Safely maps React state to Python expectation
+ })
+ });
+ } catch (err) {
+ console.warn("Failed to save settings to cloud:", err);
+ }
+ };
+
+ return {
+ isSolving,
+ isChipSolving,
+ isRunningSens,
+ pendingSolutions,
+ setPendingSolutions,
+ chipSolveSolutions,
+ setChipSolveSolutions,
+ sensResults,
+ setSensResults,
+ sensViewGw,
+ setSensViewGw,
+ handleSolve,
+ handleChipSolve,
+ handleSensAnalysis,
+ loadSettingsFromCloud,
+ saveSettingsToCloud,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000000000000000000000000000000000000..3c1bde3519b94ae1d6a3a894837c9640aa870cd0
--- /dev/null
+++ b/frontend/src/index.css
@@ -0,0 +1,61 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+
+@keyframes solve-indeterminate {
+ 0% {
+ transform: translateX(-100%);
+ }
+
+ 100% {
+ transform: translateX(400%);
+ }
+}
+
+@keyframes transfer-glow {
+
+ 0%,
+ 100% {
+ box-shadow:
+ 0 0 16px rgba(34, 211, 238, 0.35),
+ 0 0 36px rgba(16, 185, 129, 0.2),
+ inset 0 0 14px rgba(6, 182, 212, 0.12);
+ }
+
+ 50% {
+ box-shadow:
+ 0 0 28px rgba(34, 211, 238, 0.55),
+ 0 0 52px rgba(139, 92, 246, 0.18),
+ inset 0 0 22px rgba(16, 185, 129, 0.18);
+ }
+}
+
+.transfer-highlight-card {
+ animation: transfer-glow 2.4s ease-in-out infinite;
+ border-radius: 0.75rem;
+}
+
+/* ─── Global Smoothness ─── */
+html {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+* { -webkit-tap-highlight-color: transparent; }
+
+/* Tabular nums: all digits same width → no layout shift on number changes */
+.tabular-nums { font-variant-numeric: tabular-nums; }
+
+/* Skeleton loading pulse */
+@keyframes skeleton-pulse {
+ 0%, 100% { opacity: 0.12; }
+ 50% { opacity: 0.28; }
+}
+.skeleton-pulse {
+ animation: skeleton-pulse 1.8s ease-in-out infinite;
+}
+
+/* Slim themed scrollbar */
+.custom-scrollbar::-webkit-scrollbar { width: 5px; }
+.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
+.custom-scrollbar::-webkit-scrollbar-thumb { background: rgba(100,116,139,0.25); border-radius: 999px; }
+.custom-scrollbar::-webkit-scrollbar-thumb:hover { background: rgba(100,116,139,0.45); }
\ No newline at end of file
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000000000000000000000000000000000000..c2f8953aa1d8d4edf396389228c0f032b383c14e
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,14 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import App from './App.jsx';
+import './index.css';
+import { GoogleOAuthProvider } from '@react-oauth/google'; // <-- ADD THIS
+
+ReactDOM.createRoot(document.getElementById('root')).render(
+
+ {/* REPLACE WITH YOUR ACTUAL CLIENT ID */}
+
+
+
+ ,
+)
\ No newline at end of file
diff --git a/frontend/src/utils/fplLogic.js b/frontend/src/utils/fplLogic.js
new file mode 100644
index 0000000000000000000000000000000000000000..92716e66885b7fc6414ff2a5951a1f3509c08e5d
--- /dev/null
+++ b/frontend/src/utils/fplLogic.js
@@ -0,0 +1,130 @@
+// src/utils/fplLogic.js
+
+export const CHIP_CONFIG = {
+ wc: {
+ label: "Wildcard", short: "WC", desc: "Unlimited free transfers for 1 GW. FTs reset to max after.",
+ dot: "bg-yellow-400", text: "text-yellow-400", activeBg: "bg-yellow-500 text-slate-950",
+ inactiveBg: "bg-slate-800 text-slate-500 hover:bg-slate-700", border: "border-yellow-700/40", badge: "bg-yellow-500/20 text-yellow-300",
+ },
+ fh: {
+ label: "Free Hit", short: "FH", desc: "Unlimited transfers for 1 GW. Squad reverts to original after.",
+ dot: "bg-orange-400", text: "text-orange-400", activeBg: "bg-orange-500 text-slate-950",
+ inactiveBg: "bg-slate-800 text-slate-500 hover:bg-slate-700", border: "border-orange-700/40", badge: "bg-orange-500/20 text-orange-300",
+ },
+ bb: {
+ label: "Bench Boost", short: "BB", desc: "All 15 squad players score points this GW.",
+ dot: "bg-emerald-400", text: "text-emerald-400", activeBg: "bg-emerald-500 text-slate-950",
+ inactiveBg: "bg-slate-800 text-slate-500 hover:bg-slate-700", border: "border-emerald-700/40", badge: "bg-emerald-500/20 text-emerald-300",
+ },
+ tc: {
+ label: "Triple Captain", short: "TC", desc: "Captain earns 3× points instead of 2× this GW.",
+ dot: "bg-purple-400", text: "text-purple-400", activeBg: "bg-purple-500 text-white",
+ inactiveBg: "bg-slate-800 text-slate-500 hover:bg-slate-700", border: "border-purple-700/40", badge: "bg-purple-500/20 text-purple-300",
+ },
+};
+
+export const getPlayerPrice = (p) => {
+ // 1. Exact FPL Calculation if we have both purchase_price and now_cost
+ let pp = p.purchase_price !== undefined ? Number(p.purchase_price) : undefined;
+ let nc = p.now_cost !== undefined ? Number(p.now_cost) : undefined;
+
+ // FPL API sometimes sends prices multiplied by 10 (e.g., 43 for 4.3m)
+ if (pp > 20) pp = pp / 10;
+ if (nc > 20) nc = nc / 10;
+
+ if (pp !== undefined && nc !== undefined) {
+ if (nc > pp) {
+ // Gain is 0.1m for every 0.2m increase.
+ // To avoid floating point errors, multiply by 10, floor the difference/2, and divide by 10.
+ const diff = Math.round((nc - pp) * 10);
+ const gain = Math.floor(diff / 2);
+ return (Math.round(pp * 10) + gain) / 10;
+ } else {
+ // FPL Rule: You take full losses on price drops.
+ return nc;
+ }
+ }
+
+ // 2. Fallbacks if purchase_price isn't explicitly available
+ if (p.selling_price !== undefined && p.selling_price !== null) {
+ const sp = Number(p.selling_price);
+ return sp > 20 ? sp / 10 : sp;
+ }
+ if (p.sell_price !== undefined && p.sell_price !== null) {
+ const sp = Number(p.sell_price);
+ return sp > 20 ? sp / 10 : sp;
+ }
+ if (p.Price !== undefined) return Number(p.Price);
+ if (nc !== undefined) return nc;
+
+ return 0;
+};
+
+export function normalizeBenchGkFirst(teamData, gw) {
+ if (!teamData.length || teamData.length < 15 || !gw) return teamData;
+ const starters = teamData.slice(0, 11);
+ const bench = teamData.slice(11, 15);
+ const getEV = (p) => Number(p[`${gw}_Pts`]) || 0;
+ const nonBlank = bench.filter((p) => !p.isBlank);
+ const blanks = bench.filter((p) => p.isBlank);
+ const gk = nonBlank.find((p) => p.Pos === "G");
+ const outfield = nonBlank.filter((p) => p.Pos !== "G");
+ outfield.sort((a, b) => getEV(b) - getEV(a));
+ const ordered = gk ? [gk, ...outfield] : [...outfield];
+ const newBench = [...ordered, ...blanks];
+ while (newBench.length < 4) {
+ newBench.push({
+ ID: `blank_pad_${Date.now()}_${newBench.length}`,
+ isBlank: true, Pos: "M", Name: "", Team: "", Price: 0,
+ });
+ }
+ return [...starters, ...newBench.slice(0, 4)];
+}
+
+export function countSquadByPos(players) {
+ const c = { G: 0, D: 0, M: 0, F: 0 };
+ players.filter((p) => !p.isBlank && p.Pos).forEach((p) => {
+ if (c[p.Pos] !== undefined) c[p.Pos] += 1;
+ });
+ return c;
+}
+
+export function isValidFplSquad(players) {
+ const c = countSquadByPos(players);
+ return c.G === 2 && c.D === 5 && c.M === 5 && c.F === 3;
+}
+
+export const getOptimalLayout = (players, gw) => {
+ if (!players.length || !gw || players.some((p) => p.isBlank)) return null;
+ const getEV = (p) => Number(p[`${gw}_Pts`]) || 0;
+
+ let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getEV(b) - getEV(a));
+ let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getEV(b) - getEV(a));
+ let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getEV(b) - getEV(a));
+ let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getEV(b) - getEV(a));
+
+ const starters = [];
+ if (gks.length) starters.push(gks.shift());
+ starters.push(...defs.splice(0, 3), ...mids.splice(0, 2), ...fwds.splice(0, 1));
+
+ const remaining = [...defs, ...mids, ...fwds].sort((a, b) => getEV(b) - getEV(a));
+ starters.push(...remaining.splice(0, 11 - starters.length));
+
+ const finalStarters = [
+ ...starters.filter((p) => p.Pos === "G"),
+ ...starters.filter((p) => p.Pos === "D"),
+ ...starters.filter((p) => p.Pos === "M"),
+ ...starters.filter((p) => p.Pos === "F"),
+ ];
+
+ const benchGk = gks.length ? gks[0] : null;
+ const benchRest = remaining.sort((a, b) => getEV(b) - getEV(a));
+ const bench = benchGk ? [benchGk, ...benchRest] : benchRest;
+ const topStarters = [...finalStarters].sort((a, b) => getEV(b) - getEV(a));
+
+ return {
+ optimalArray: [...finalStarters, ...bench],
+ cap: topStarters[0]?.ID,
+ vice: topStarters[1]?.ID,
+ };
+};
\ No newline at end of file
diff --git a/frontend/src/utils/teams.js b/frontend/src/utils/teams.js
new file mode 100644
index 0000000000000000000000000000000000000000..5b0402a43fb0f082e7255cab742be769da19e603
--- /dev/null
+++ b/frontend/src/utils/teams.js
@@ -0,0 +1,12 @@
+export const TEAM_SHORTS = {
+ "Arsenal": "ARS", "Aston Villa": "AVL", "Burnley": "BUR", "AFC Bournemouth": "BOU",
+ "Brentford": "BRE", "Brighton and Hove Albion": "BHA", "Chelsea": "CHE",
+ "Crystal Palace": "CRY", "Everton": "EVE", "Fulham": "FUL", "Leeds United": "LEE",
+ "Liverpool": "LIV", "Manchester City": "MCI", "Manchester United": "MUN",
+ "Newcastle United": "NEW", "Nottingham Forest": "NFO", "Sunderland": "SUN",
+ "Tottenham Hotspur": "TOT", "West Ham United": "WHU", "Wolverhampton Wanderers": "WOL",
+ "Bournemouth": "BOU", "Brighton": "BHA", "Man City": "MCI", "Man Utd": "MUN",
+ "Newcastle": "NEW", "Nott'm Forest": "NFO", "Spurs": "TOT", "West Ham": "WHU", "Wolves": "WOL"
+};
+
+export const getShortName = (fullName) => TEAM_SHORTS[fullName] || fullName;
\ No newline at end of file
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..8ae8f8a21ffc0a62ea1c88f3f73b9b785fddae4d
--- /dev/null
+++ b/frontend/tailwind.config.js
@@ -0,0 +1,22 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ content: [
+ "./index.html",
+ "./src/**/*.{js,ts,jsx,tsx}",
+ ],
+ theme: {
+ extend: {
+ colors: {
+ // Adding some custom Luigi's Mansion ghostly greens
+ luigi: {
+ 400: '#34d399',
+ 500: '#10b981',
+ 900: '#064e3b',
+ }
+ }
+ },
+ },
+ plugins: [],
+}
+
+
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000000000000000000000000000000000000..8b0f57b91aeb45c54467e29f983a0893dc83c4d9
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/logos/1044.png b/logos/1044.png
new file mode 100644
index 0000000000000000000000000000000000000000..dc1ea6028b8984f02eee2d525604605d8c99414f
--- /dev/null
+++ b/logos/1044.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2703d4d22a050434985764fd8512bdc83dc967b022f854668811b03aa05f2155
+size 7589
diff --git a/logos/328.png b/logos/328.png
new file mode 100644
index 0000000000000000000000000000000000000000..d759816851e664ed7eb5eeea828fc074416016f8
--- /dev/null
+++ b/logos/328.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a418607981308fd980be2246d4885b2f6794ebad33e0980315dd90e392b707e5
+size 12909
diff --git a/logos/341.png b/logos/341.png
new file mode 100644
index 0000000000000000000000000000000000000000..8b250c8ca2ac5737251581cbf9905e5ae95d78f4
--- /dev/null
+++ b/logos/341.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:16e5872c0127c093f14e8338b2ba1d79bc307cc24dcc690ca820c85c3af056f0
+size 6893
diff --git a/logos/351.png b/logos/351.png
new file mode 100644
index 0000000000000000000000000000000000000000..4aaeee2d3da669a41fc6d79d2945e3c8b1db6647
--- /dev/null
+++ b/logos/351.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d4098e62317e19e6dc92d806ded0e77ba7714b22728998466a441aef0c8e9025
+size 5102
diff --git a/logos/354.png b/logos/354.png
new file mode 100644
index 0000000000000000000000000000000000000000..5ae4596c2ca2a42031d7d7473594fb742a92a07e
--- /dev/null
+++ b/logos/354.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:8cd5323ea26846f9991bd5604ae63e124f81e5e3feda7716a3f038ab5151458c
+size 15450
diff --git a/logos/397.png b/logos/397.png
new file mode 100644
index 0000000000000000000000000000000000000000..d4db6849e05fdcd5e8b5ac224ac44346fba39444
--- /dev/null
+++ b/logos/397.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b2a60525561839e8a113d95ec80598f8398a28b568124e3d9b15c69c651f5d07
+size 7468
diff --git a/logos/402.png b/logos/402.png
new file mode 100644
index 0000000000000000000000000000000000000000..bf51d87496c6b552a7580e53485564027d3138a0
--- /dev/null
+++ b/logos/402.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:165adcf2e24288dcabd3e5d5e5de3b83e58e7c549aaa2c6101a37159a12c1753
+size 15476
diff --git a/logos/563.png b/logos/563.png
new file mode 100644
index 0000000000000000000000000000000000000000..52bcb21e8b7ec046a864a7169bc2e0a7b331ca33
--- /dev/null
+++ b/logos/563.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:11e0ae907552888ef9190265cdef5f9b0f3e23052575870dc9fcf47ddca71898
+size 6859
diff --git a/logos/57.png b/logos/57.png
new file mode 100644
index 0000000000000000000000000000000000000000..fce6b314c2e8c347d708f5b95c53f9ea048ee2be
--- /dev/null
+++ b/logos/57.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:36e761b46213d8fa6deb1ddd94add8b61060a74fab48ca77a1f28827ff37d209
+size 10279
diff --git a/logos/58.png b/logos/58.png
new file mode 100644
index 0000000000000000000000000000000000000000..98197d310f141b39c35baf07377d5f49edd38172
--- /dev/null
+++ b/logos/58.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:cd9d0e04bb7d781bca31fb8575de5ede409e4775db07a2cf64da7cbc3ee93202
+size 5924
diff --git a/logos/61.png b/logos/61.png
new file mode 100644
index 0000000000000000000000000000000000000000..61ca6ad7754f5c97ea0aecc53f2f00b5ee253e8f
--- /dev/null
+++ b/logos/61.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:bb79d3ccd973afb7802d4d48fb9b58de8940e9ac2183b66df28d83b61c1bfc6a
+size 14278
diff --git a/logos/62.png b/logos/62.png
new file mode 100644
index 0000000000000000000000000000000000000000..ac5484d9540a1a1de09c5d361f774602157fbef3
--- /dev/null
+++ b/logos/62.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:9d8062665589e8089e67175fe479efb2a647b6bd0ec7883a9602fc9557a788d7
+size 12393
diff --git a/logos/63.png b/logos/63.png
new file mode 100644
index 0000000000000000000000000000000000000000..1885a205ecb2dd3e78c7608c9dfc71d12c8826b3
--- /dev/null
+++ b/logos/63.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:c84fb63fdd0a20e9268e2e5d967fd459b15c1e4194f8d4197a84a071b68187e2
+size 3728
diff --git a/logos/64.png b/logos/64.png
new file mode 100644
index 0000000000000000000000000000000000000000..466b465442e1585ebdbe29ba4095cf70a884422a
--- /dev/null
+++ b/logos/64.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:e98ce271542dfc14ecaee2b5b6b228841a8ca19751f537eacec2fe66820fbcce
+size 15035
diff --git a/logos/65.png b/logos/65.png
new file mode 100644
index 0000000000000000000000000000000000000000..be37e5d5e93879c1dd73b34441bd3973a4a24ac5
--- /dev/null
+++ b/logos/65.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:d3602d5a8ecc240c6685d935a502fe56167d127ee53810e3ff618d2bba9db432
+size 14815
diff --git a/logos/66.png b/logos/66.png
new file mode 100644
index 0000000000000000000000000000000000000000..7161781abc29de26f21e15e2732788ef36245df9
--- /dev/null
+++ b/logos/66.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5bd8bd07d24ac133a75c2c7d93886b5d586cb84a41281378a98b0ef827850846
+size 16317
diff --git a/logos/67.png b/logos/67.png
new file mode 100644
index 0000000000000000000000000000000000000000..d965b4266bd6ab64c8e65868fbcf30655c61bcfb
--- /dev/null
+++ b/logos/67.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:93ac1da648d20a87e9661564752e32f7d7be2e3d835917807207713149f2b571
+size 16245
diff --git a/logos/71.png b/logos/71.png
new file mode 100644
index 0000000000000000000000000000000000000000..94f9cb67aff36c70a5faa93cd1bb66ef0645dba2
--- /dev/null
+++ b/logos/71.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7690bab9be9d1712314b3982efcca442649f9ea75ad9ca3c4cce54c3621a35aa
+size 11052
diff --git a/logos/73.png b/logos/73.png
new file mode 100644
index 0000000000000000000000000000000000000000..2597048b64a8bdb6d8917c363e48b62f11eff3d3
--- /dev/null
+++ b/logos/73.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:2cfcd7ca5dd94737e52e965f5a44de45da5d08181410737722cd7d9929c5b1a4
+size 4281
diff --git a/logos/76.png b/logos/76.png
new file mode 100644
index 0000000000000000000000000000000000000000..bdb29e165a4f903687deed35c220fad4b8b50316
--- /dev/null
+++ b/logos/76.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ab19a729b23877189ac54b7ff7c5b8c10d46adcea98d04b782beff0d4380e950
+size 3038
diff --git a/main.py b/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..97ca6f2f86a3ab75a0bf002af61cf743ddbc5a60
--- /dev/null
+++ b/main.py
@@ -0,0 +1,1148 @@
+import itertools as _itertools
+import json
+import os
+from typing import Any, Dict, List, Optional
+
+import numpy as np
+import pandas as pd
+import requests
+from fastapi import FastAPI, HTTPException, APIRouter, Depends
+from fastapi.middleware.cors import CORSMiddleware
+from pydantic import BaseModel
+from sqlalchemy.orm import Session
+from sqlalchemy.orm.attributes import flag_modified
+
+import engine
+from auth import router as auth_router
+from fpl_api import get_fpl_team_data
+from solver import run_milp_model
+from solver_engine import prep_solver_data
+from database import get_db, User, SessionLocal, GlobalConfig
+
+
+# --- PYDANTIC MODELS FOR REACT PAYLOAD ---
+class PlayerData(BaseModel):
+ id: int
+ name: str
+ pos: str
+ team: str
+ now_cost: float
+ sell_price: Optional[float] = None
+ evs: Dict[str, float] # JSON keys are strings; horizon GW keyed
+
+
+class SolveRequest(BaseModel):
+ team_id: int
+ horizon_gws: List[int]
+ current_squad_ids: List[Any]
+ market_players: List[PlayerData]
+ in_the_bank: float
+ free_transfers: int
+ settings: dict = {}
+ comprehensive_settings: dict = {}
+
+
+class ChipSolveRequest(BaseModel):
+ team_id: int
+ horizon_gws: List[int]
+ current_squad_ids: List[Any]
+ market_players: List[PlayerData]
+ in_the_bank: float
+ free_transfers: int
+ settings: dict = {}
+ comprehensive_settings: dict = {}
+ # { "wc": [gw, ...], "fh": [gw, ...], "bb": [gw, ...], "tc": [gw, ...] }
+ chip_gw_options: Dict[str, List[int]] = {}
+
+
+class SettingsPayload(BaseModel):
+ team_id: int
+ quick_settings: Dict[str, Any]
+ advanced_settings: Dict[str, Any]
+
+
+app = FastAPI(title="Luigi's Mansion FPL API")
+
+
+@app.get("/")
+def read_root():
+ return {
+ "status": "success",
+ "message": "Luigi's Mansion FPL Solver API is live and running!",
+ }
+
+
+app.include_router(auth_router)
+
+app.add_middleware(
+ CORSMiddleware,
+ allow_origins=["*"],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+)
+
+ADMIN_PASSWORD = "Monkeyrocks11$$"
+TEAMS_DICT = {
+ "Arsenal": 1,
+ "Aston Villa": 2,
+ "Burnley": 3,
+ "AFC Bournemouth": 4,
+ "Brentford": 5,
+ "Brighton and Hove Albion": 6,
+ "Chelsea": 7,
+ "Crystal Palace": 8,
+ "Everton": 9,
+ "Fulham": 10,
+ "Leeds United": 11,
+ "Liverpool": 12,
+ "Manchester City": 13,
+ "Manchester United": 14,
+ "Newcastle United": 15,
+ "Nottingham Forest": 16,
+ "Sunderland": 17,
+ "Tottenham Hotspur": 18,
+ "West Ham United": 19,
+ "Wolverhampton Wanderers": 20,
+}
+TEAMS_DICT_REVERSE = {v: k for k, v in TEAMS_DICT.items()}
+POS_MAP = {1: "G", 2: "D", 3: "M", 4: "F"}
+
+POINTS_CONFIG = {
+ "goal": {1: 10, 2: 6, 3: 5, 4: 4},
+ "assist": 3,
+ "clean_sheet": {1: 4, 2: 4, 3: 1, 4: 0},
+ "saves_per_3": 1,
+ "penalty_points_per_position": {2: 0.9, 3: 0.7, 4: 0.5},
+}
+
+
+class AppData:
+ finalized_df = None
+ match_df = None
+ output_df = None
+
+ # All JSON States
+ player_penalty_shares = {}
+ admin_xmins_overrides = {}
+ admin_baseline_overrides = {}
+ player_status_overrides = {}
+ availability_multipliers = {}
+ admin_fixture_overrides = {}
+
+ decay_rates = {
+ "default": 0.99,
+ "suspended": 0.99,
+ "injured_decay": 0.99,
+ "rotational_risk": 0.95,
+ }
+ ramp_up_rates = {
+ "default": 3,
+ "injured": 9,
+ "suspended": 3,
+ "starter": 0,
+ "rotational_risk": 2,
+ }
+ MINS_THRESHOLD = 30
+ RAMP_UP_PERIOD = 3
+
+
+app_data = AppData()
+
+
+def load_db_int_keys(db_key, default):
+ db = SessionLocal()
+ try:
+ config = db.query(GlobalConfig).filter(GlobalConfig.key == db_key).first()
+ if config and config.value:
+ return {int(k): v for k, v in config.value.items()}
+ finally:
+ db.close()
+ return default
+
+
+def load_db_string_keys(db_key, default):
+ db = SessionLocal()
+ try:
+ config = db.query(GlobalConfig).filter(GlobalConfig.key == db_key).first()
+ if config and config.value:
+ return config.value
+ finally:
+ db.close()
+ return default
+
+
+def save_config_to_db(db_key, value):
+ db = SessionLocal()
+ try:
+ config = db.query(GlobalConfig).filter(GlobalConfig.key == db_key).first()
+ if config:
+ config.value = value
+ flag_modified(config, "value")
+ else:
+ config = GlobalConfig(key=db_key, value=value)
+ db.add(config)
+ db.commit()
+ finally:
+ db.close()
+
+
+def load_fpl_data():
+ print("Fetching FPL API data...")
+ r = requests.get(
+ "https://fantasy.premierleague.com/api/bootstrap-static/", timeout=10
+ ).json()
+
+ players = pd.DataFrame(r["elements"])
+ players["name"] = players["first_name"] + " " + players["second_name"]
+ players = players[
+ [
+ "id",
+ "name",
+ "web_name",
+ "element_type",
+ "now_cost",
+ "team",
+ "chance_of_playing_this_round",
+ "news",
+ "photo",
+ ]
+ ]
+ players["now_cost"] = players["now_cost"] / 10
+
+ if os.path.exists("rename.json"):
+ with open("rename.json", "r", encoding="utf-8") as f:
+ players["name"] = players["name"].replace(json.load(f))
+
+ print("Loading baseline stats...")
+ gk_stats_df = pd.read_csv("statistical_weighted_baselines_gk.csv").rename(
+ columns=lambda x: x.strip()
+ )
+ outfield_stats_df = pd.read_csv("statistical_weighted_baselines.csv").rename(
+ columns=lambda x: x.strip()
+ )
+ gk_stats_df["player_name"] = gk_stats_df["player_name"].str.strip()
+ outfield_stats_df["player_name"] = outfield_stats_df["player_name"].str.strip()
+
+ gk_mask = players["element_type"] == 1
+ gk_merged = players[gk_mask].merge(
+ gk_stats_df, left_on="name", right_on="player_name", how="left"
+ )
+ outfield_merged = players[~gk_mask].merge(
+ outfield_stats_df, left_on="name", right_on="player_name", how="left"
+ )
+
+ final_df = (
+ pd.concat([gk_merged, outfield_merged], ignore_index=True)
+ .sort_values("id")
+ .reset_index(drop=True)
+ )
+ final_df.fillna(0, inplace=True)
+
+ final_df["Avg_BPS"] = 0.0
+ final_df.loc[final_df["element_type"] == 1, "Avg_BPS"] = final_df[
+ "baseline_gk_bps_p90"
+ ].astype(float)
+ final_df.loc[final_df["element_type"] == 2, "Avg_BPS"] = (
+ final_df["baseline_Neutral_BPS_p90"] + final_df["baseline_Def_BPS_p90"]
+ )
+ final_df.loc[final_df["element_type"] == 3, "Avg_BPS"] = (
+ final_df["baseline_Neutral_BPS_p90"] + final_df["baseline_Mid_BPS_p90"]
+ )
+ final_df.loc[final_df["element_type"] == 4, "Avg_BPS"] = (
+ final_df["baseline_Neutral_BPS_p90"] + final_df["baseline_Fwd_BPS_p90"]
+ )
+
+ print("Loading team and match projections...")
+ team_baselines = pd.read_excel("team_totals.xlsx", sheet_name="Sheet2")
+ team_baselines["Teams"] = team_baselines["Teams"].replace(TEAMS_DICT)
+
+ for stat in ["xG", "xA", "CBIT", "CBITR", "YC", "RC"]:
+ final_df[f"Team_{stat}"] = final_df["team"].map(
+ team_baselines.set_index("Teams")[stat].to_dict()
+ )
+
+ final_df["Team"] = final_df["team"].map(TEAMS_DICT_REVERSE)
+
+ final_df["xG_share"] = final_df["baseline_xG_p90"] / final_df["Team_xG"].replace(
+ 0, np.nan
+ )
+ final_df["xA_share"] = final_df["baseline_xA_p90"] / final_df["Team_xA"].replace(
+ 0, np.nan
+ )
+ final_df["xCBIT_share"] = final_df["baseline_CBIT_p90"] / final_df[
+ "Team_CBIT"
+ ].replace(0, np.nan)
+ final_df["xCBITR_share"] = final_df["baseline_CBITR_p90"] / final_df[
+ "Team_CBITR"
+ ].replace(0, np.nan)
+ final_df["YC_share"] = final_df["baseline_yc_p90"] / final_df["Team_YC"].replace(
+ 0, np.nan
+ )
+ final_df["RC_share"] = final_df["baseline_rc_p90"] / final_df["Team_RC"].replace(
+ 0, np.nan
+ )
+ final_df.fillna(0, inplace=True)
+
+ match_df = pd.read_csv("ewmapois_model.csv").rename(columns=lambda x: x.strip())
+ match_df["home_team_num"] = match_df["home_team"].map(TEAMS_DICT)
+ match_df["away_team_num"] = match_df["away_team"].map(TEAMS_DICT)
+
+ app_data.finalized_df = final_df
+ app_data.match_df = match_df
+
+ # --- LOAD ALL DB OVERRIDES ---
+ app_data.player_penalty_shares = load_db_int_keys(
+ "penalty_shares", {16: 0.65, 17: 0.15}
+ )
+
+ raw_xmins = load_db_int_keys("admin_xmins", {})
+ processed_overrides = {}
+ for pid, gws in raw_xmins.items():
+ processed_gws = {}
+ for gw_key, val in gws.items():
+ if str(gw_key).isdigit():
+ processed_gws[int(gw_key)] = val
+ else:
+ processed_gws[str(gw_key)] = val
+ processed_overrides[int(pid)] = processed_gws
+ app_data.admin_xmins_overrides = processed_overrides
+
+ app_data.admin_baseline_overrides = load_db_int_keys("admin_baselines", {})
+ app_data.player_status_overrides = load_db_int_keys("player_status", {})
+ app_data.availability_multipliers = load_db_int_keys("availability", {})
+ app_data.admin_fixture_overrides = load_db_string_keys("admin_fixtures", {})
+
+ # --- THE FALLBACK LOGIC ---
+ # Apply baseline JSON overrides on top of the CSV data. If not in JSON, it naturally keeps the CSV data.
+ for pid, overrides in app_data.admin_baseline_overrides.items():
+ if pid in app_data.finalized_df["id"].values:
+ if "baseline_xMins" in overrides:
+ app_data.finalized_df.loc[
+ app_data.finalized_df["id"] == pid, "baseline_xMins"
+ ] = overrides["baseline_xMins"]
+
+ print("Running initial FPL point engine...")
+ app_data.output_df = engine.calculate_all_points(
+ player_df_base=app_data.finalized_df,
+ match_df=app_data.match_df,
+ player_penalty_shares=app_data.player_penalty_shares,
+ MINS_SCALING_BONUS=0.0,
+ pos_map=POS_MAP,
+ teams_dict_1=TEAMS_DICT_REVERSE,
+ teams_dict=TEAMS_DICT,
+ points_config=POINTS_CONFIG,
+ effective_xmins_overrides=app_data.admin_xmins_overrides,
+ MINS_THRESHOLD=app_data.MINS_THRESHOLD,
+ RAMP_UP_PERIOD=app_data.RAMP_UP_PERIOD,
+ decay_rates=app_data.decay_rates,
+ ramp_up_rates=app_data.ramp_up_rates,
+ user_player_status_overrides=app_data.player_status_overrides,
+ team_skepticism={},
+ effective_availability_multipliers=app_data.availability_multipliers,
+ )
+
+ # Inject baseline_xMins into the output so the frontend can display it
+ app_data.output_df["baseline_xMins"] = app_data.output_df["ID"].map(
+ app_data.finalized_df.set_index("id")["baseline_xMins"]
+ )
+ # --- BULLETPROOF ADVANCED STATS INJECTION ---
+ try:
+ finalized_idx = app_data.finalized_df.set_index("id")
+
+ # 1. Add photo and Price so Transfer Market can display images and cost
+ if "photo" in finalized_idx.columns:
+ app_data.output_df["photo"] = app_data.output_df["ID"].map(
+ finalized_idx["photo"]
+ )
+ if "now_cost" in finalized_idx.columns:
+ app_data.output_df["Price"] = app_data.output_df["ID"].map(
+ finalized_idx["now_cost"]
+ )
+
+ # 2. Safely map xG and xA
+ app_data.output_df["xG"] = app_data.output_df["ID"].map(
+ lambda pid: round(
+ (finalized_idx.loc[pid, "baseline_xG_p90"] / 90)
+ * finalized_idx.loc[pid, "baseline_xMins"],
+ 2,
+ )
+ if pid in finalized_idx.index and "baseline_xG_p90" in finalized_idx.columns
+ else 0
+ )
+ app_data.output_df["xA"] = app_data.output_df["ID"].map(
+ lambda pid: round(
+ (finalized_idx.loc[pid, "baseline_xA_p90"] / 90)
+ * finalized_idx.loc[pid, "baseline_xMins"],
+ 2,
+ )
+ if pid in finalized_idx.index and "baseline_xA_p90" in finalized_idx.columns
+ else 0
+ )
+
+ # 3. Safely get CS%
+ unique_gws = sorted(app_data.match_df["GW"].unique())
+ for gw in unique_gws:
+ if f"{gw}_xG" in app_data.output_df.columns:
+ # Format CS% for the GW
+ app_data.output_df[f"{gw}_CS_Pct"] = (
+ app_data.output_df[f"{gw}_CS"] * 100
+ ).apply(lambda x: f"{x:.0f}%")
+
+ # Calculate HIT% for the GW using the stored CBIT / CBITR
+ def calc_hit_gw(row):
+ pos = row["Pos"]
+ if pos == "D":
+ cbit = row.get(f"{gw}_cbit", 0)
+ prob = engine.neg_binom_probability_at_least(
+ cbit, 10, dispersion=3.2
+ )
+ elif pos == "M":
+ cbitr = row.get(f"{gw}_cbitr", 0)
+ prob = engine.neg_binom_probability_at_least(
+ cbitr, 12, dispersion=2.8
+ )
+ elif pos == "F":
+ cbitr = row.get(f"{gw}_cbitr", 0)
+ prob = engine.neg_binom_probability_at_least(
+ cbitr, 12, dispersion=1.7
+ )
+ else:
+ return "-"
+ return f"{prob * 100:.0f}%"
+
+ app_data.output_df[f"{gw}_DefconHit"] = app_data.output_df.apply(
+ calc_hit_gw, axis=1
+ )
+
+ except Exception as e:
+ print(f"WARNING: Could not inject advanced stats. Reason: {e}")
+
+
+@app.on_event("startup")
+def startup_event():
+ load_fpl_data()
+
+
+@app.get("/api/projections")
+def get_projections():
+ if app_data.output_df is None:
+ raise HTTPException(status_code=503, detail="Loading")
+ clean_df = app_data.output_df.where(pd.notnull(app_data.output_df), None)
+ return clean_df.to_dict(orient="records")
+
+
+class UpdateRequest(BaseModel):
+ player_id: int
+ baseline_edit: Optional[float] = None
+ gw_edits: Dict[str, float] = {}
+ is_admin: bool = False
+ admin_password: Optional[str] = None
+
+
+@app.post("/api/player/update")
+def update_player(req: UpdateRequest):
+ if req.is_admin:
+ if req.admin_password != ADMIN_PASSWORD:
+ raise HTTPException(status_code=401, detail="Invalid admin password")
+
+ # Save Admin Edits directly to the JSON files
+ if req.baseline_edit is not None:
+ if req.player_id not in app_data.admin_baseline_overrides:
+ app_data.admin_baseline_overrides[req.player_id] = {}
+ app_data.admin_baseline_overrides[req.player_id]["baseline_xMins"] = (
+ req.baseline_edit
+ )
+ save_config_to_db("admin_baselines", app_data.admin_baseline_overrides)
+
+ for gw_str, xmins in req.gw_edits.items():
+ gw_key = int(gw_str) if str(gw_str).isdigit() else str(gw_str)
+ if req.player_id not in app_data.admin_xmins_overrides:
+ app_data.admin_xmins_overrides[req.player_id] = {}
+ app_data.admin_xmins_overrides[req.player_id][gw_key] = xmins
+ if req.gw_edits:
+ save_config_to_db("admin_xmins", app_data.admin_xmins_overrides)
+
+ player_df = app_data.finalized_df[
+ app_data.finalized_df["id"] == req.player_id
+ ].copy()
+ if player_df.empty:
+ raise HTTPException(status_code=404, detail="Player not found")
+
+ current_baseline = player_df.iloc[0]["baseline_xMins"]
+ if req.baseline_edit is not None:
+ player_df["baseline_xMins"] = req.baseline_edit
+ current_baseline = req.baseline_edit
+
+ effective_overrides = {
+ req.player_id: app_data.admin_xmins_overrides.get(req.player_id, {}).copy()
+ }
+ for gw_str, val in req.gw_edits.items():
+ gw_key = int(gw_str) if str(gw_str).isdigit() else str(gw_str)
+ effective_overrides[req.player_id][gw_key] = val
+
+ # Recalculate using all the existing status/penalty configs
+ updated_row_df = engine.calculate_all_points(
+ player_df_base=player_df,
+ match_df=app_data.match_df,
+ player_penalty_shares=app_data.player_penalty_shares,
+ MINS_SCALING_BONUS=0.0,
+ pos_map=POS_MAP,
+ teams_dict_1=TEAMS_DICT_REVERSE,
+ teams_dict=TEAMS_DICT,
+ points_config=POINTS_CONFIG,
+ effective_xmins_overrides=effective_overrides,
+ MINS_THRESHOLD=app_data.MINS_THRESHOLD,
+ RAMP_UP_PERIOD=app_data.RAMP_UP_PERIOD,
+ decay_rates=app_data.decay_rates,
+ ramp_up_rates=app_data.ramp_up_rates,
+ user_player_status_overrides=app_data.player_status_overrides,
+ team_skepticism={},
+ effective_availability_multipliers=app_data.availability_multipliers,
+ )
+
+ updated_row_df["baseline_xMins"] = current_baseline
+ if req.is_admin and app_data.output_df is not None:
+ idx = app_data.output_df[app_data.output_df["ID"] == req.player_id].index
+ if not idx.empty:
+ for col in updated_row_df.columns:
+ if col in app_data.output_df.columns:
+ app_data.output_df.loc[idx, col] = updated_row_df[col].values[0]
+ return updated_row_df.iloc[0].to_dict()
+
+
+@app.get("/api/ratings")
+def get_ratings():
+ import base64
+
+ if os.path.exists("team_ratings_dual_speed.csv"):
+ df = pd.read_csv("team_ratings_dual_speed.csv")
+ df.rename(columns=lambda x: x.strip(), inplace=True)
+
+ # Team IDs mapping for the logos
+ team_ids = {
+ "Arsenal": 57,
+ "Manchester City": 65,
+ "Liverpool": 64,
+ "Chelsea": 61,
+ "Newcastle United": 67,
+ "Aston Villa": 58,
+ "Manchester United": 66,
+ "Brentford": 402,
+ "Brighton and Hove Albion": 397,
+ "AFC Bournemouth": 1044,
+ "Tottenham Hotspur": 73,
+ "Crystal Palace": 354,
+ "Fulham": 63,
+ "Nottingham Forest": 351,
+ "Everton": 62,
+ "Leeds United": 341,
+ "West Ham United": 563,
+ "Wolverhampton Wanderers": 76,
+ "Sunderland": 71,
+ "Burnley": 328,
+ }
+
+ def get_logo(t_name):
+ tid = team_ids.get(t_name)
+ logo_path = f"logos/{tid}.png"
+ if tid and os.path.exists(logo_path):
+ with open(logo_path, "rb") as f:
+ return (
+ "data:image/png;base64," + base64.b64encode(f.read()).decode()
+ )
+ return None
+
+ df["Logo"] = df["Team"].apply(get_logo)
+ return df.to_dict(orient="records")
+ return []
+
+
+@app.get("/api/fixtures")
+def get_fixtures():
+ if app_data.match_df is not None:
+ cols = [
+ "GW",
+ "home_team",
+ "away_team",
+ "home_win_prob",
+ "draw_prob",
+ "away_win_prob",
+ "expected_home_goals",
+ "expected_away_goals",
+ "home_clean_sheet_odds",
+ "away_clean_sheet_odds",
+ ]
+ df = app_data.match_df[cols].copy()
+
+ for col in [
+ "home_win_prob",
+ "draw_prob",
+ "away_win_prob",
+ "expected_home_goals",
+ "expected_away_goals",
+ "home_clean_sheet_odds",
+ "away_clean_sheet_odds",
+ ]:
+ df[col] = df[col].astype(float).round(3)
+
+ return df.to_dict(orient="records")
+ return []
+
+
+@app.get("/api/accuracy/players")
+def get_accuracy_players():
+ import os
+
+ import pandas as pd
+
+ file_path = "points_check.xlsx"
+ if os.path.exists(file_path):
+ df = pd.read_excel(file_path)
+ df.columns = df.columns.str.strip()
+ df.fillna(0, inplace=True)
+ return df.to_dict(orient="records")
+
+ # Fallback just in case
+ csv_path = "points_check.xlsx - Sheet1.csv"
+ if os.path.exists(csv_path):
+ df = pd.read_csv(csv_path)
+ df.columns = df.columns.str.strip()
+ df.fillna(0, inplace=True)
+ return df.to_dict(orient="records")
+ return []
+
+
+@app.get("/api/accuracy/matches")
+def get_accuracy_matches():
+ import os
+
+ import pandas as pd
+
+ file_path = "projections_check.xlsx"
+ if os.path.exists(file_path):
+ df = pd.read_excel(file_path)
+ df.columns = df.columns.str.strip()
+ df.fillna(0, inplace=True)
+ return df.to_dict(orient="records")
+
+ # Fallback just in case
+ csv_path = "projections_check.xlsx - Sheet1.csv"
+ if os.path.exists(csv_path):
+ df = pd.read_csv(csv_path)
+ df.columns = df.columns.str.strip()
+ df.fillna(0, inplace=True)
+ return df.to_dict(orient="records")
+ return []
+
+
+@app.get("/api/manager/{team_id}")
+async def get_manager_team(team_id: int):
+ try:
+ # 1. Run the precise open-fpl-solver logic to get ITB, FTs, and Selling Prices
+ fpl_data = get_fpl_team_data(team_id)
+
+ team_data = []
+
+ # 2. Merge the official FPL data with your local Projection Data
+ for pick in fpl_data["squad"]:
+ pid = pick["id"]
+
+ # Find the player in your master projections dataframe
+ proj_row = app_data.output_df[app_data.output_df["ID"] == pid]
+ if proj_row.empty:
+ continue
+
+ proj_dict = proj_row.iloc[0].to_dict()
+
+ # --- CRITICAL FIX: TRUE SELLING PRICE ---
+ # Overwrite the global "Cost Price" with the user's personal "Selling Price"
+ proj_dict["Price"] = pick["selling_price"]
+
+ # Add photos if needed
+ base_row = app_data.finalized_df[app_data.finalized_df["id"] == pid]
+ proj_dict["photo"] = (
+ base_row.iloc[0]["photo"]
+ if not base_row.empty and "photo" in base_row.columns
+ else ""
+ )
+
+ team_data.append(proj_dict)
+
+ # 3. Send the exact payload React is expecting!
+ return {
+ "in_the_bank": fpl_data["in_the_bank"],
+ "free_transfers": fpl_data["free_transfers"],
+ "picks": team_data,
+ }
+
+ except Exception as e:
+ print(f"Error fetching team: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+def _load_default_solver_settings() -> dict:
+ path = os.path.join(os.path.dirname(__file__), "comprehensive_settings.json")
+ if os.path.exists(path):
+ with open(path, "r", encoding="utf-8") as f:
+ return json.load(f)
+ return {}
+
+
+@app.get("/api/solver/default-settings")
+def get_solver_default_settings():
+ return _load_default_solver_settings()
+
+
+@app.post("/api/solve")
+async def run_solver(payload: SolveRequest):
+ try:
+ data_dict = (
+ payload.model_dump() if hasattr(payload, "model_dump") else payload.dict()
+ )
+
+ # 2. Run the Data Prepper
+ solver_input = prep_solver_data(data_dict)
+
+ # 3. Run the Math Engine
+ optimal_moves = run_milp_model(solver_input)
+
+ return optimal_moves
+ except Exception as e:
+ print(f"Error: {e}")
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+class SensitivityRequest(SolveRequest):
+ num_sims: int = 50
+ analysis_gw: Optional[int] = None # kept for compat, ignored internally
+
+
+@app.post("/api/sensitivity")
+async def run_sensitivity_analysis(payload: SensitivityRequest):
+ """
+ Runs num_sims solves (iterations=1, per-player noise). For regular GWs
+ aggregates buy/sell/move transfers; for WC/FH GWs aggregates squad
+ selection % (PSB), lineup %, and positional lineup combinations.
+ """
+ import random
+ from collections import Counter
+
+ try:
+ data_dict = (
+ payload.model_dump() if hasattr(payload, "model_dump") else payload.dict()
+ )
+ num_sims = int(data_dict.pop("num_sims", 50))
+ data_dict.pop("analysis_gw", None)
+ horizon_gws = [int(g) for g in data_dict.get("horizon_gws", [])]
+
+ base_settings = dict(data_dict.get("settings") or {})
+ base_settings["iterations"] = 1
+
+ # Detect which GWs have WC/FH active
+ chip_free_gws: set[int] = set()
+ for chip_key in ("use_wc", "use_fh"):
+ for g in base_settings.get(chip_key) or []:
+ chip_free_gws.add(int(g))
+
+ id_to_name: dict[int, str] = {}
+ id_to_pos: dict[int, str] = {}
+ for p in data_dict["market_players"]:
+ pid = int(p["id"])
+ id_to_name[pid] = p["name"]
+ id_to_pos[pid] = p["pos"]
+
+ print(
+ f"Sensitivity: running {num_sims} sims across {len(horizon_gws)} GWs "
+ f"(chip-free GWs: {chip_free_gws or 'none'})..."
+ )
+
+ # Regular GW accumulators
+ gw_data: dict[int, dict] = {
+ gw: {"buys": {}, "sells": {}, "moves": {}, "lineups": {}, "no_transfer": 0}
+ for gw in horizon_gws
+ if gw not in chip_free_gws
+ }
+
+ # WC/FH GW accumulators: per-player squad & lineup counts + combo counters
+ wc_data: dict[int, dict] = {
+ gw: {
+ "squad": {}, # {name: count} (in the 15-man squad)
+ "lineup": {}, # {name: count} (in the 11-man lineup)
+ "combos": {
+ "G": Counter(),
+ "D": Counter(),
+ "M": Counter(),
+ "F": Counter(),
+ },
+ }
+ for gw in horizon_gws
+ if gw in chip_free_gws
+ }
+
+ valid_runs = 0
+
+ for sim_idx in range(num_sims):
+ noisy_players = []
+ for p in data_dict["market_players"]:
+ noise = random.gauss(1.0, 0.12)
+ noise = max(0.3, min(2.5, noise))
+ noisy_evs = {k: round(float(v) * noise, 4) for k, v in p["evs"].items()}
+ noisy_players.append({**p, "evs": noisy_evs})
+
+ sim_data = {
+ **data_dict,
+ "market_players": noisy_players,
+ "settings": {**base_settings},
+ }
+
+ try:
+ solver_input = prep_solver_data(sim_data)
+ result = run_milp_model(solver_input)
+ if result["status"] != "success" or not result["solutions"]:
+ continue
+ sol = result["solutions"][0]
+ except Exception as sim_err:
+ print(f" Sim {sim_idx + 1} failed: {sim_err}")
+ continue
+
+ valid_runs += 1
+
+ for gw_plan in sol["plan"]:
+ gw = gw_plan["gw"]
+
+ if gw in wc_data:
+ # --- WC/FH GW: squad & lineup selection ---
+ all_ids = list(gw_plan.get("lineup", [])) + list(
+ gw_plan.get("bench", [])
+ )
+ lineup_ids = set(gw_plan.get("lineup", []))
+ for pid in all_ids:
+ name = id_to_name.get(pid, str(pid))
+ wc_data[gw]["squad"][name] = (
+ wc_data[gw]["squad"].get(name, 0) + 1
+ )
+ if pid in lineup_ids:
+ wc_data[gw]["lineup"][name] = (
+ wc_data[gw]["lineup"].get(name, 0) + 1
+ )
+
+ # Lineup combos per position
+ for pid in lineup_ids:
+ pass # we accumulate below
+ pos_players: dict[str, list[str]] = {
+ "G": [],
+ "D": [],
+ "M": [],
+ "F": [],
+ }
+ for pid in sorted(lineup_ids):
+ pos = id_to_pos.get(pid, "M")
+ name = id_to_name.get(pid, str(pid))
+ if pos in pos_players:
+ pos_players[pos].append(name)
+ for pos, names in pos_players.items():
+ combo = frozenset(names)
+ if combo:
+ wc_data[gw]["combos"][pos][combo] += 1
+
+ elif gw in gw_data:
+ # --- Regular GW: buy/sell/move ---
+ transfers_out_ids = gw_plan.get("transfers_out", [])
+ transfers_in_ids = gw_plan.get("transfers_in", [])
+
+ if not transfers_in_ids:
+ gw_data[gw]["no_transfer"] += 1
+ else:
+ buy_names = [
+ id_to_name.get(pid, str(pid)) for pid in transfers_in_ids
+ ]
+ sell_names = [
+ id_to_name.get(pid, str(pid)) for pid in transfers_out_ids
+ ]
+ for name in buy_names:
+ gw_data[gw]["buys"][name] = (
+ gw_data[gw]["buys"].get(name, 0) + 1
+ )
+ for name in sell_names:
+ gw_data[gw]["sells"][name] = (
+ gw_data[gw]["sells"].get(name, 0) + 1
+ )
+
+ sorted_buys = sorted(buy_names)
+ sorted_sells = sorted(sell_names)
+ if sorted_buys and sorted_sells:
+ mk = (
+ f"{', '.join(sorted_sells)} -> {', '.join(sorted_buys)}"
+ )
+ gw_data[gw]["moves"][mk] = (
+ gw_data[gw]["moves"].get(mk, 0) + 1
+ )
+
+ for pid in gw_plan.get("lineup", []):
+ name = id_to_name.get(pid, str(pid))
+ gw_data[gw]["lineups"][name] = (
+ gw_data[gw]["lineups"].get(name, 0) + 1
+ )
+
+ if valid_runs == 0:
+ raise Exception(
+ "All sensitivity simulations failed. Check squad/budget settings."
+ )
+
+ def to_pct_list(counter: dict, top_n: int = 20) -> list:
+ return [
+ {"name": k, "count": v, "pct": round(v / valid_runs * 100, 1)}
+ for k, v in sorted(counter.items(), key=lambda x: -x[1])[:top_n]
+ ]
+
+ name_to_pos: dict[str, str] = {v: id_to_pos[k] for k, v in id_to_name.items()}
+
+ gw_results: dict[str, dict] = {}
+
+ # --- Regular GW results ---
+ for gw in horizon_gws:
+ if gw in chip_free_gws:
+ continue
+ if gw not in gw_data:
+ continue
+ d = gw_data[gw]
+ pos_groups: dict[str, list] = {"G": [], "D": [], "M": [], "F": []}
+ for name, cnt in sorted(d["lineups"].items(), key=lambda x: -x[1]):
+ pos = name_to_pos.get(name, "M")
+ pct = round(cnt / valid_runs * 100, 1)
+ if pos in pos_groups:
+ pos_groups[pos].append({"name": name, "pct": pct, "count": cnt})
+ gw_results[str(gw)] = {
+ "is_chip_free": False,
+ "buys": to_pct_list(d["buys"]),
+ "sells": to_pct_list(d["sells"]),
+ "moves": to_pct_list(d["moves"]),
+ "lineups": {pos: rows[:8] for pos, rows in pos_groups.items()},
+ "no_transfer_pct": round(d["no_transfer"] / valid_runs * 100, 1),
+ }
+
+ # --- WC/FH GW results ---
+ for gw in horizon_gws:
+ if gw not in wc_data:
+ continue
+ wd = wc_data[gw]
+
+ # Per-player squad/lineup pct grouped by position
+ player_data_by_pos: dict[str, list] = {"G": [], "D": [], "M": [], "F": []}
+ all_names = set(list(wd["squad"].keys()) + list(wd["lineup"].keys()))
+ for name in all_names:
+ sq_cnt = wd["squad"].get(name, 0)
+ lu_cnt = wd["lineup"].get(name, 0)
+ pos = name_to_pos.get(name, "M")
+ if pos in player_data_by_pos:
+ player_data_by_pos[pos].append(
+ {
+ "name": name,
+ "squad_pct": round(sq_cnt / valid_runs * 100, 1),
+ "lineup_pct": round(lu_cnt / valid_runs * 100, 1),
+ "squad_count": sq_cnt,
+ "lineup_count": lu_cnt,
+ }
+ )
+ # Sort each position by squad_pct descending
+ for pos in player_data_by_pos:
+ player_data_by_pos[pos].sort(key=lambda x: -x["squad_pct"])
+ player_data_by_pos[pos] = player_data_by_pos[pos][:12]
+
+ # Lineup combos per position (top 5)
+ combo_data: dict[str, list] = {}
+ for pos in ("G", "D", "M", "F"):
+ combos = wd["combos"][pos]
+ sorted_combos = sorted(combos.items(), key=lambda x: -x[1])[:5]
+ combo_data[pos] = [
+ {
+ "combination": ", ".join(sorted(combo)),
+ "pct": round(cnt / valid_runs * 100, 1),
+ "count": cnt,
+ }
+ for combo, cnt in sorted_combos
+ ]
+
+ gw_results[str(gw)] = {
+ "is_chip_free": True,
+ "players": player_data_by_pos,
+ "combos": combo_data,
+ }
+
+ return {
+ "status": "success",
+ "num_sims": num_sims,
+ "valid_runs": valid_runs,
+ "horizon_gws": horizon_gws,
+ "gw_results": gw_results,
+ }
+
+ except Exception as e:
+ print(f"Sensitivity error: {e}")
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+def _generate_chip_combos(chip_gw_options: dict) -> list[dict]:
+ """
+ Generate all valid chip combinations from the per-chip GW option lists.
+ Rules:
+ - At most one chip per GW.
+ - Each chip type used at most once.
+ - Returns list of dicts like {"use_wc": [], "use_fh": [37], "use_bb": [33], "use_tc": []}.
+ """
+ chip_types = ["wc", "fh", "bb", "tc"]
+ options: list[list] = []
+ for c in chip_types:
+ gws = [int(g) for g in (chip_gw_options.get(c) or [])]
+ options.append([None] + gws) # None = don't use this chip
+
+ valid: list[dict] = []
+ for combo in _itertools.product(*options):
+ # combo = (wc_gw|None, fh_gw|None, bb_gw|None, tc_gw|None)
+ used_gws = [g for g in combo if g is not None]
+ if len(used_gws) != len(set(used_gws)):
+ continue # Two chips assigned to same GW — invalid
+ valid.append(
+ {
+ f"use_{c}": ([g] if g is not None else [])
+ for c, g in zip(chip_types, combo)
+ }
+ )
+ return valid
+
+
+@app.post("/api/chip-solve")
+async def run_chip_solver(payload: ChipSolveRequest):
+ """
+ Evaluate all valid chip combinations from the supplied option lists and
+ return the top solutions ranked by objective score.
+
+ Chip-solve uses fixed settings: decay=1.017, ft_value=0,
+ ft_value_list all zeros, itb_value=0, ft_use_penalty=0.
+ """
+ try:
+ data_dict = (
+ payload.model_dump() if hasattr(payload, "model_dump") else payload.dict()
+ )
+ chip_gw_options: dict = data_dict.pop("chip_gw_options", {})
+
+ # Fixed chip-solve settings (per run_parallel.py conventions)
+ chip_fixed = {
+ "decay": 1.017,
+ "ft_value": 0.0,
+ "ft_value_list": {"2": 0, "3": 0, "4": 0, "5": 0},
+ "itb_value": 0.0,
+ "ft_use_penalty": 0.0,
+ "no_transfer_last_gws": 0,
+ "iterations": 1,
+ }
+ base_settings = {**data_dict.get("settings", {}), **chip_fixed}
+
+ combos = _generate_chip_combos(chip_gw_options)
+ if not combos:
+ raise Exception(
+ "No valid chip combinations generated from the supplied GW options."
+ )
+
+ # Cap at 30 combinations to keep runtime reasonable
+ combos = combos[:30]
+ print(f"Chip solve: evaluating {len(combos)} valid chip combination(s)...")
+
+ all_solutions = []
+ for idx, combo in enumerate(combos):
+ combo_settings = {**base_settings, **combo}
+ combo_data = {**data_dict, "settings": combo_settings}
+ try:
+ solver_input = prep_solver_data(combo_data)
+ result = run_milp_model(solver_input)
+ if result["status"] == "success" and result["solutions"]:
+ sol = result["solutions"][0]
+ sol["chip_combo"] = combo # tag which chips were used
+ sol["combo_id"] = idx + 1
+ all_solutions.append(sol)
+ except Exception as combo_err:
+ print(f" Combo {idx + 1} ({combo}) failed: {combo_err}")
+ continue
+
+ if not all_solutions:
+ raise Exception("All chip combinations failed to find optimal solutions.")
+
+ all_solutions.sort(
+ key=lambda s: -(float(s.get("objective_score") or s.get("ev") or 0))
+ )
+
+ return {"status": "success", "solutions": all_solutions[:10]}
+
+ except Exception as e:
+ print(f"Chip solve error: {e}")
+ raise HTTPException(status_code=400, detail=str(e))
+
+
+@app.post("/api/settings/save")
+async def save_user_settings(payload: SettingsPayload, db: Session = Depends(get_db)):
+ user = db.query(User).filter(User.default_team_id == payload.team_id).first()
+ if not user:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ user.solver_settings = {
+ "quick": payload.quick_settings,
+ "advanced": payload.advanced_settings,
+ }
+
+ # THE FIX: Violently force SQLAlchemy to commit the JSON column
+ flag_modified(user, "solver_settings")
+
+ db.commit()
+ return {"status": "success", "message": "Settings saved to cloud."}
+
+
+# 3. The LOAD Route (GET)
+@app.get("/api/settings/{team_id}")
+async def load_user_settings(team_id: int, db: Session = Depends(get_db)):
+ # Find the user by their team_id
+ user = db.query(User).filter(User.default_team_id == team_id).first()
+
+ if not user or not user.solver_settings:
+ # If no user or no settings, return nulls so React uses local defaults
+ return {"status": "success", "quick_settings": None, "advanced_settings": None}
+
+ return {
+ "status": "success",
+ "quick_settings": user.solver_settings.get("quick"),
+ "advanced_settings": user.solver_settings.get("advanced"),
+ }
+
+
+class FixtureOverrideRequest(BaseModel):
+ overrides: Dict[str, Any]
+ is_admin: bool = False
+ admin_password: Optional[str] = None
+
+
+@app.get("/api/fixtures/overrides")
+def get_fixture_overrides():
+ # Serves the global fixtures to everyone who loads the website
+ return app_data.admin_fixture_overrides
+
+
+@app.post("/api/fixtures/update")
+def update_fixture_overrides(req: FixtureOverrideRequest):
+ if req.is_admin:
+ if req.admin_password != ADMIN_PASSWORD:
+ raise HTTPException(status_code=401, detail="Invalid admin password")
+
+ # 1. Update Python's active RAM instantly!
+ app_data.admin_fixture_overrides = req.overrides
+
+ # 2. Save it to the Hard Drive permanently
+ save_config_to_db("admin_fixtures", app_data.admin_fixture_overrides)
+
+ return {"status": "success", "message": "Global fixtures updated!"}
+
+ raise HTTPException(status_code=401, detail="Unauthorized")
+
+
+@app.get("/api/xmins/overrides")
+def get_xmins_overrides():
+ return app_data.admin_xmins_overrides
diff --git a/player_groups.json b/player_groups.json
new file mode 100644
index 0000000000000000000000000000000000000000..1523bee0b034df4e58c939210cd5653241d18159
--- /dev/null
+++ b/player_groups.json
@@ -0,0 +1,112 @@
+{
+ "arsenal attack 1": [
+ "Saka (16)",
+ "Gy\u00f6keres (666)",
+ "Madueke (18)",
+ "Eze (266)",
+ "Trossard (20)",
+ "Martinelli (19)"
+ ],
+ "bou attack 1": [
+ "Tavernier (84)",
+ "Kluivert (81)",
+ "Adli (697)",
+ "Brooks (86)"
+ ],
+ "bou attack 2": [
+ "Evanilson (97)",
+ "Kroupi.Jr (100)",
+ "Enes \u00dcnal (98)"
+ ],
+ "brentford 1": [
+ "O.Dango (83)",
+ "Schade (120)",
+ "Thiago (136)"
+ ],
+ "brighton 1": [
+ "Minteh (160)",
+ "Georginio (158)",
+ "Welbeck (178)",
+ "Mitoma (157)",
+ "Gruda (162)",
+ "Gomez (169)"
+ ],
+ "chelsea 1": [
+ "Palmer (235)",
+ "Enzo (237)",
+ "Buonanotte (168)"
+ ],
+ "chelsea 2": [
+ "Jo\u00e3o Pedro (249)",
+ "Delap (250)"
+ ],
+ "palace 2": [
+ "Sarr (267)",
+ "Yeremy (712)",
+ "Kamada (271)",
+ "Devenny (276)",
+ "Mateta (283)",
+ "Johnson (580)"
+ ],
+ "everton 1 ": [
+ "Beto (311)",
+ "Barry (310)"
+ ],
+ "everton 2": [
+ "Ndiaye (299)",
+ "McNeil (300)",
+ "Grealish (419)",
+ "Dewsbury-Hall (242)",
+ "Alcaraz (301)"
+ ],
+ "fulham 2": [
+ "Smith Rowe (325)",
+ "King (335)",
+ "Iwobi (324)",
+ "Wilson (329)",
+ "Chukwueze (727)",
+ "Muniz (338)",
+ "Ra\u00fal (337)"
+ ],
+ "leeds 1": [
+ "Calvert-Lewin (691)",
+ "Nmecha (365)",
+ "Okafor (698)"
+ ],
+ "liverpool 2": [
+ "M.Salah (381)",
+ "Szoboszlai (387)",
+ "Gakpo (384)",
+ "Mac Allister (386)",
+ "Wirtz (382)",
+ "Ekitik\u00e9 (661)",
+ "Isak (499)"
+ ],
+ "city 1": [
+ "Foden (414)",
+ "Cherki (417)",
+ "Reijnders (427)",
+ "Bernardo (416)",
+ "Doku (418)",
+ "Savinho (415)",
+ "Semenyo (82)",
+ "Haaland (430)"
+ ],
+ "newcastle 1": [
+ "Woltemade (714)",
+ "Wissa (135)"
+ ],
+ "newcastle 2": [
+ "Gordon (485)",
+ "Elanga (486)",
+ "J.Murphy (489)",
+ "Barnes (487)"
+ ],
+ "whu": [
+ "Bowen (624)",
+ "L.Paquet\u00e1 (612)",
+ "Taty (791)",
+ "Pablo (785)",
+ "Summerville (615)"
+ ]
+}
\ No newline at end of file
diff --git a/player_penalty_shares.json b/player_penalty_shares.json
new file mode 100644
index 0000000000000000000000000000000000000000..b7a62df76ebd5bf60a7a37c1d15808d110a4fb03
--- /dev/null
+++ b/player_penalty_shares.json
@@ -0,0 +1,76 @@
+{
+ "16": 0.45,
+ "17": 0.1,
+ "30": 0.05,
+ "666": 0.24,
+ "48": 0.25,
+ "64": 0.3,
+ "81": 0.9,
+ "699": 0.5,
+ "97": 0.05,
+ "136": 0.8,
+ "121": 0.09,
+ "178": 0.85,
+ "158": 0.18,
+ "202": 0.25,
+ "215": 0.41,
+ "216": 0.02,
+ "235": 0.9,
+ "236": 0.7,
+ "249": 0.1,
+ "266": 0.0,
+ "267": 0.1,
+ "283": 0.9,
+ "299": 0.8,
+ "311": 0.1,
+ "310": 0.1,
+ "337": 0.75,
+ "327": 0.0,
+ "343": 0.07,
+ "362": 0.7,
+ "381": 0.9,
+ "382": 0.0,
+ "386": 0.05,
+ "430": 0.93,
+ "413": 0.15,
+ "449": 0.89,
+ "119": 0.1,
+ "450": 0.0,
+ "499": 0.0,
+ "485": 0.5,
+ "474": 0.02,
+ "525": 0.4,
+ "515": 0.3,
+ "596": 0.7,
+ "612": 0.0,
+ "624": 0.84,
+ "625": 0.04,
+ "647": 0.1,
+ "654": 0.7,
+ "561": 0.6,
+ "663": 0.15,
+ "164": 0.75,
+ "597": 0.65,
+ "135": 0.0,
+ "82": 0.3,
+ "237": 0.0,
+ "691": 0.1,
+ "714": 0.23,
+ "547": 0.1,
+ "544": 0.6,
+ "84": 0.6,
+ "100": 0.4,
+ "517": 0.3,
+ "387": 0.35,
+ "120": 0.22,
+ "365": 0.4,
+ "681": 0.1,
+ "414": 0.2,
+ "526": 0.3,
+ "783": 0.1,
+ "791": 0.15,
+ "642": 0.65,
+ "817": 0.1,
+ "720": 0.0,
+ "709": 0.25
+}
\ No newline at end of file
diff --git a/points_check.xlsx b/points_check.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..7a291319266317b3377601e3558c9104cd6216c6
--- /dev/null
+++ b/points_check.xlsx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:74fad5732cfe070578e5723946bc86ae432f0f5deef2779c035a1c2f69102f0a
+size 323598
diff --git a/projections_check.xlsx b/projections_check.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..94856b80bf4a7c35275ae539d29bdbab81412c25
--- /dev/null
+++ b/projections_check.xlsx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:52c65e30c04a9a4cad3d1ee775d65ad26b9340ed880a62d468ef9b1f6071f2f5
+size 29713
diff --git a/rates_config.json b/rates_config.json
new file mode 100644
index 0000000000000000000000000000000000000000..ade3711644792b40dc458d97b41d9f0918efe93b
--- /dev/null
+++ b/rates_config.json
@@ -0,0 +1,17 @@
+{
+ "decay_rates": {
+ "default": 0.98,
+ "suspended": 0.94,
+ "injured_decay": 0.94,
+ "rotational_risk": 0.94
+ },
+ "ramp_up_rates": {
+ "default": 2.0,
+ "injured": 8.0,
+ "suspended": 4.0,
+ "starter": 0.0,
+ "rotational_risk": 0.0
+ },
+ "RAMP_UP_PERIOD": 3,
+ "MINS_THRESHOLD": 35
+}
\ No newline at end of file
diff --git a/rename.json b/rename.json
new file mode 100644
index 0000000000000000000000000000000000000000..1672bda6897be03071cd050a8c987e4d54afd383
--- /dev/null
+++ b/rename.json
@@ -0,0 +1,204 @@
+{
+ "David Raya Martín": "David Raya",
+ "Pablo Felipe Pereira de Jesus": "Pablo",
+ "Amad Diallo": "Amad",
+ "Đorđe Petrović": "Djordje Petrovic",
+ "Alysson Edward Franco da Rocha dos Santos": "Alysson Edward",
+ "Junior Kroupi": "Junior Kroupi",
+ "Mateo Kovačić": "Mateo Kovacic",
+ "Saša Lukić": "Sasa Lukic",
+ "Tomáš Souček": "Tomas Soucek",
+ "Joško Gvardiol": "Josko Gvardiol",
+ "Mohamadou Kanté": "Mohamadou Kante",
+ "Álex Jiménez Sánchez": "Álex Jiménez",
+ "Alex Tóth": "Alex Toth",
+ "Veljko Milosavljević": "Veljko Milosavljevic",
+ "Mateus Gonçalo Espanha Fernandes": "Mateus Fernandes",
+ "Hwang Hee-chan": "Hee-Chan Hwang ",
+ "Mats Wieffer": "Mats Wieffer",
+ "Yéremy Pino Santos": "Yéremi Pino",
+ "Luis Eduardo Soares da Silva": "Cuiabano",
+ "Kevin Santos Lopes de Macedo": "Kevin",
+ "Florentino Ibrain Morris Luís": "Florentino",
+ "John Victor Maciel Furtado": "John Victor",
+ "Manuel Ugarte Ribeiro": "Manuel Ugarte",
+ "Douglas Luiz Soares de Paulo": "Douglas Luiz",
+ "Ben Gannon-Doak": "Ben Gannon Doak",
+ "Benjamin Šeško": "Benjamin Sesko",
+ "Dan Burn": "Daniel Burn",
+ "João Maria Lobo Alves Palhares Costa Palhinha Gonçalves": "Joao Palhinha",
+ "Diego León Blanco": "Diego León",
+ "Blondy Nna Noukeu": "Blondy Noukeu",
+ "Saša Kalajdžić": "Sasa Kalajdzic",
+ "Lucas Estella Perri": "Lucas Perri",
+ "Nico O’Reilly": "Nico O'Reilly",
+ "Josh King": "Joshua King",
+ "Jaydon Banel": "Jaydon Amauri Banel",
+ "Aarón Anselmino": "Aaron Anselmino",
+ "Kim Ji-soo": "Kim Jisoo",
+ "Paris Maghoma": "Edmond-Paris Maghoma",
+ "Joe Hodge": "Joseph Hodge",
+ "Joe Gomez": "Joseph Gomez",
+ "Manuel Benson Hedilazio": "Benson Manuel",
+ "Zépiqueno Redmond": "Zepiqueno Redmond",
+ "Ben Broggio": "Benjamin Broggio",
+ "Kepa Arrizabalaga Revuelta": "Kepa Arrizabalaga",
+ "Karl Hein": "Karl Jakob Hein",
+ "Emiliano Martínez Romero": "Emiliano Martínez",
+ "Norberto Murara Neto": "Neto",
+ "Callan McKenna": "Callan Mckenna",
+ "Robert Lynch Sánchez": "Robert Sánchez",
+ "Filip Jørgensen": "Filip Jörgensen",
+ "Gabriel Słonina": "Gabriel Slonina",
+ "Alisson": "Alisson Becker",
+ "Walter Benítez": "Walter Benitez",
+ "Altay Bayındır": "Altay Bayindir",
+ "Ederson Santana de Moraes": "Ederson",
+ "Stefan Ortega Moreno": "Stefan Ortega",
+ "Carlos Miguel dos Santos Pereira": "Carlos Miguel",
+ "José Malheiro de Sá": "José Sá",
+ "Gabriel dos Santos Magalhães": "Gabriel",
+ "Jurriën Timber": "Jurriën Timber",
+ "Benjamin White": "Ben White",
+ "Gabriel Martinelli Silva": "Gabriel Martinelli",
+ "Mikel Merino Zazón": "Mikel Merino",
+ "Fábio Ferreira Vieira": "Fábio Vieira",
+ "Martín Zubimendi Ibáñez": "Martín Zubimendi",
+ "Andrés García": "Andres Garcia",
+ "Gabriel Fernando de Jesus": "Gabriel Jesus",
+ "Ezri Konsa Ngoyo": "Ezri Konsa",
+ "Álex Moreno Lopera": "Álex Moreno",
+ "Lino da Cruz Sousa": "Lino Sousa",
+ "Emiliano Buendía Stati": "Emiliano Buendía",
+ "Victor Lindelöf": "Victor Nilsson Lindelöf",
+ "Jamaldeen Jimoh-Aloba": "Jamaldeen Jimoh",
+ "Lucas Pires Silva": "Lucas Pires",
+ "Darko Churlinov": "Darko Gyabi",
+ "Mike Trésor Ndayishimiye": "Mike Trésor",
+ "Marcos Senesi Barón": "Marcos Senesi",
+ "Julián Araujo Zúñiga": "Julián Araujo",
+ "Julio Soler Barreto": "Julio Soler",
+ "Luis Sinisterra Lucumí": "Luis Sinisterra",
+ "Francisco Evanilson de Lima Barbosa": "Evanilson",
+ "Mads Roerslev Rasmussen": "Mads Roerslev",
+ "Fábio Freitas Gouveia Carvalho": "Fábio Carvalho",
+ "Gustavo Nunes Fernandes Gomes": "Gustavo Nunes",
+ "Igor Thiago Nascimento Rodrigues": "Igor Thiago",
+ "Joel Veltman": "Joël Veltman",
+ "De Cuyper": "Maxim De Cuyper",
+ "Max Weiß": "Max Weiss",
+ "Václav Hladký": "Vaclav Hladky",
+ "Pervis Estupiñán Tenorio": "Pervis Estupiñán",
+ "Ferdi Kadıoğlu": "Ferdi Kadioglu",
+ "Igor Julio dos Santos de Paulo": "Igor Julio",
+ "Mitoma Kaoru": "Kaoru Mitoma",
+ "Julio Enciso Espínola": "Julio Enciso",
+ "Matt O'Riley": "Matthew O'Riley",
+ "Diego Gómez Amarilla": "Diego Gómez",
+ "Jeremy Sarmiento Morante": "Jeremy Sarmiento",
+ "Malick Yalcouyé": "Malick Yalcouye",
+ "Marc Cucurella Saseta": "Marc Cucurella",
+ "Levi Samuels Colwill": "Levi Colwill",
+ "Cheick Doucouré": "Cheick Oumar Doucouré",
+ "Benoît Badiashile Mukinayi": "Benoît Badiashile",
+ "Joshua Acheampong": "Josh Acheampong",
+ "Joe Willock": "Joseph Willock",
+ "Pedro Lomba Neto": "Pedro Neto",
+ "Estêvão Almeida de Oliveira Gonçalves": "Estêvão",
+ "Roméo Lavia": "Romeo Lavia",
+ "Jamie Bynoe-Gittens": "Jamie Gittens",
+ "Moisés Caicedo Corozo": "Moisés Caicedo",
+ "Andrey Nascimento dos Santos": "Andrey Santos",
+ "Dário Luís Essugo": "Dário Essugo",
+ "Kendry Páez Andrade": "Kendry Páez",
+ "Radu Drăgușin": "Radu Dragusin",
+ "João Pedro Junqueira de Jesus": "João Pedro",
+ "Pape Matar Sarr": "Pape Sarr",
+ "Marc Guiu Paz": "Marc Guiu",
+ "Daniel Muñoz Mejía": "Daniel Muñoz",
+ "Chadi Riad Dnanou": "Chadi Riad",
+ "Miodrag Pivaš": "Miodrag Pivas",
+ "Jefferson Lerma Solís": "Jefferson Lerma",
+ "Matheus França de Oliveira": "Matheus França",
+ "Zach Marsh": "Zachariah Marsh",
+ "Vitalii Mykolenko": "Vitaliy Mykolenko",
+ "Carlos Alcaraz Durán": "Carlos Alcaraz",
+ "Sékou Koné": "Sekou Koné",
+ "Norberto Bercique Gomes Betuncal": "Beto",
+ "Youssef Ramalho Chermiti": "Youssef Chermiti",
+ "Jorge Cuenca Barreno": "Jorge Cuenca",
+ "Adama Traoré Diarra": "Adama Traoré",
+ "Andreas Hoelgebaum Pereira": "Andreas Pereira",
+ "Raúl Jiménez Rodríguez": "Raul Jiménez",
+ "João Victor de Souza Menezes": "Souza",
+ "Rayan Vitor Simplício Rocha": "Rayan",
+ "Tino Livramento": "Valentino Livramento",
+ "Rodrigo Muniz Carvalho": "Rodrigo Muniz",
+ "Tanaka Ao": "Ao Tanaka",
+ "Mateo Joseph Fernández-Regatillo": "Mateo Joseph",
+ "Konstantinos Tsimikas": "Kostas Tsimikas",
+ "Luis Díaz Marulanda": "Luis Díaz",
+ "Endo Wataru": "Wataru Endo",
+ "Stefan Bajčetić Maquieira": "Stefan Bajcetic",
+ "Darwin Núñez Ribeiro": "Darwin Núñez",
+ "Rúben dos Santos Gato Alves Dias": "Rúben Dias",
+ "Vitor de Oliveira Nunes dos Reis": "Vitor Reis",
+ "Sávio Moreira de Oliveira": "Savinho",
+ "Bernardo Mota Veiga de Carvalho e Silva": "Bernardo Silva",
+ "Nikola Milenković": "Nikola Milenkovic",
+ "Nicolás Domínguez": "Nicolás Dominguez",
+ "Leo Fuhr Hjelde": "Leo Hjelde",
+ "Rodrigo 'Rodri' Hernandez Cascante": "Rodri",
+ "Nico González Iglesias": "Nico González",
+ "Diogo Dalot Teixeira": "Diogo Dalot",
+ "Bruno Borges Fernandes": "Bruno Fernandes",
+ "Matheus Santos Carneiro da Cunha": "Matheus Cunha",
+ "Dan Neil": "Daniel Neil",
+ "Alejandro Garnacho Ferreyra": "Alejandro Garnacho",
+ "Antony dos Santos": "Antony",
+ "Carlos Henrique Casimiro": "Casemiro",
+ "Chido Obi": "Chidozie Obi-Martin",
+ "Aji Alese": "Ajibola Alese",
+ "Bruno Guimarães Rodriguez Moura": "Bruno Guimarães",
+ "Joelinton Cássio Apolinário de Lira": "Joelinton",
+ "Antoñito Cordero Campillo": "Antonio Cordero",
+ "Murillo Costa dos Santos": "Murillo",
+ "David Mota Veiga Teixeira do Carmo": "David Carmo",
+ "Jair Paula da Cunha Filho": "Jair Cunha",
+ "Felipe Rodrigues da Silva": "Morato",
+ "João Pedro Ferreira da Silva": "Jota Silva",
+ "Igor Jesus Maciel da Cruz": "Igor Jesus",
+ "Jesurun Rak-Sakyi": "Jesurun Rak Sakyi",
+ "Marko Stamenić": "Marko Stamenic",
+ "Timothée Pembélé": "Timothee Pembele",
+ "Reinildo Mandava": "Reinildo",
+ "Ian Poveda-Ocampo": "Ian Poveda",
+ "Eliezer Mayenda Dossou": "Eliezer Mayenda",
+ "Luís Hemir Silva Semedo": "Luís Hemir",
+ "Nazarii Rusyn": "Nazariy Rusyn",
+ "Pedro Porro Sauceda": "Pedro Porro",
+ "Takai Kōta": "Kōta Takai",
+ "Bryan Gil Salvatierra": "Bryan Gil",
+ "Ollie Scarles": "Oliver Scarles",
+ "Eddie Nketiah": "Edward Nketiah",
+ "Dominic Solanke-Mitchell": "Dominic Solanke",
+ "Richarlison de Andrade": "Richarlison",
+ "El Hadji Malick Diouf": "Malick Diouf",
+ "Emerson Palmieri dos Santos": "Emerson Palmieri",
+ "Maximilian Kilman": "Max Kilman",
+ "Lucas Tolentino Coelho de Lima": "Lucas Paquetá",
+ "Edson Álvarez Velázquez": "Edson Álvarez",
+ "Luis Guilherme Lira dos Santos": "Luis Guilherme",
+ "Hugo Bueno López": "Hugo Bueno",
+ "Yerson Mosquera Valdelamar": "Yerson Mosquera",
+ "Rodrigo Martins Gomes": "Rodrigo Gomes",
+ "Santiago Ignacio Bueno": "Santiago Bueno",
+ "Bastien Meupiyou Menadjou": "Bastien Meupiyou",
+ "Pedro Cardoso de Lima": "Pedro Lima",
+ "André Trindade da Costa Neto": "André",
+ "Fer López González": "Fer López",
+ "João Victor Gomes da Silva": "João Gomes",
+ "Gonçalo Manuel Ganchinho Guedes": "Gonçalo Guedes",
+ "Enso González Medina": "Enso Gonzalez",
+ "Fábio Soares Silva": "Fábio Silva"
+}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..fb5b1323b1731b6c5615dbfb529cfae427b7dfd2
Binary files /dev/null and b/requirements.txt differ
diff --git a/solver.py b/solver.py
new file mode 100644
index 0000000000000000000000000000000000000000..6a1b12d635c6d7fa0b9cdde6ea8a8846fea392dd
--- /dev/null
+++ b/solver.py
@@ -0,0 +1,607 @@
+import pulp
+
+
+def run_milp_model(data: dict):
+ print("Igniting Advanced Iterative MILP Engine (Original Solver Parity)...")
+
+ players = data["players"]
+ gws = data["gws"]
+ buy_prices = data["buy_prices"]
+ sell_prices = data["sell_prices"]
+ positions = data["positions"]
+ teams = data["teams"]
+ ev_matrix = data["ev_matrix"]
+ raw_ev_matrix = data.get("raw_ev_matrix", ev_matrix)
+ current_squad = data["current_squad"]
+ start_itb = data["itb"]
+ start_ft = data["ft"]
+
+ settings = data.get("settings", {})
+
+ # --- STRICT SINGLE SOURCE OF TRUTH ---
+ # Python no longer guesses. It extracts exactly what React tells it to.
+ time_limit_sec = int(settings.get("secs", settings.get("time_limit_sec")))
+ iterations = int(settings.get("iterations", 1))
+ iteration_diff = int(settings.get("iteration_diff", 1))
+ iteration_criteria = settings.get("iteration_criteria", "this_gw_transfer_in_out")
+ banned_ids = settings.get("banned_ids", [])
+ locked_ids = settings.get("locked_ids", [])
+
+ # Directly extracting required scalar values from the React payload
+ hit_cost = float(settings["hit_cost"])
+ max_ft = int(settings["max_ft"])
+ itb_value = float(settings["itb_value"])
+ vice_weight = float(settings.get("vcap_weight", settings["vice_weight"]))
+ max_per_team = int(settings["max_per_team"])
+ no_transfer_last_gws = int(settings["no_transfer_last_gws"])
+ itb_loss_per_transfer = float(settings["itb_loss_per_transfer"])
+ ft_use_penalty = float(settings["ft_use_penalty"])
+ decay_base = float(settings.get("decay_base", 1.0))
+
+ # --- FT STATE VALUATION ---
+ raw_ft_value = float(settings["ft_value"])
+ use_ftvl_flag = str(settings.get("use_ft_value_list", "false")).lower() in [
+ "true",
+ "1",
+ ]
+ raw_ftvl = settings.get("ft_value_list", {}) if use_ftvl_flag else {}
+
+ ft_states_list = list(range(max_ft + 1))
+ ft_state_value = {}
+ for s in ft_states_list:
+ val = float(raw_ftvl.get(str(s), raw_ft_value)) if raw_ftvl else raw_ft_value
+ ft_state_value[s] = ft_state_value.get(s - 1, 0.0) + val
+
+ # --- CHIP SETTINGS ---
+ use_wc = [int(g) for g in (settings.get("use_wc") or [])]
+ use_fh = [int(g) for g in (settings.get("use_fh") or [])]
+ use_bb = [int(g) for g in (settings.get("use_bb") or [])]
+ use_tc = [int(g) for g in (settings.get("use_tc") or [])]
+
+ # Bench weights (Strictly trusts the payload dictionary)
+ raw_bw = settings["bench_weights"]
+ bench_weights = {int(k): float(v) for k, v in raw_bw.items()}
+ gk_bench_w = bench_weights.get(0, 0.03)
+ of_bench_ws = [bench_weights.get(i, 0.0) for i in [1, 2, 3]]
+ avg_of_bench_w = sum(of_bench_ws) / len(of_bench_ws) if of_bench_ws else 0.05
+
+ chip_gws: dict[int, str] = {}
+ for g in use_wc:
+ if g in gws:
+ chip_gws[g] = "wc"
+ for g in use_fh:
+ if g in gws:
+ chip_gws[g] = "fh"
+ for g in use_bb:
+ if g in gws:
+ chip_gws[g] = "bb"
+ for g in use_tc:
+ if g in gws:
+ chip_gws[g] = "tc"
+
+ all_gws = [gws[0] - 1] + gws
+
+ # --- FREE HIT SQUAD REVERSION ---
+ effective_prev: dict[int, int] = {}
+ for w in gws:
+ prev_w = all_gws[all_gws.index(w) - 1]
+ if chip_gws.get(prev_w) == "fh":
+ fh_prev_idx = all_gws.index(prev_w) - 1
+ effective_prev[w] = all_gws[fh_prev_idx]
+ else:
+ effective_prev[w] = prev_w
+
+ prob = pulp.LpProblem("Luigis_Mansion_FPL_Solver", pulp.LpMaximize)
+
+ # --- DECISION VARIABLES ---
+ squad = pulp.LpVariable.dicts("squad", (players, all_gws), cat="Binary")
+ lineup = pulp.LpVariable.dicts("lineup", (players, gws), cat="Binary")
+ captain = pulp.LpVariable.dicts("captain", (players, gws), cat="Binary")
+ vice_captain = pulp.LpVariable.dicts("vice_captain", (players, gws), cat="Binary")
+ transfer_in = pulp.LpVariable.dicts("transfer_in", (players, gws), cat="Binary")
+ transfer_out = pulp.LpVariable.dicts("transfer_out", (players, gws), cat="Binary")
+ itb = pulp.LpVariable.dicts("itb", all_gws, lowBound=0, cat="Continuous")
+ fts = pulp.LpVariable.dicts(
+ "fts", all_gws, lowBound=1, upBound=max_ft, cat="Integer"
+ )
+ hits = pulp.LpVariable.dicts("hits", gws, lowBound=0, cat="Integer")
+
+ ft_below_lb = pulp.LpVariable.dicts("ft_below_lb", gws, cat="Binary")
+ ft_above_ub = pulp.LpVariable.dicts("ft_above_ub", gws, cat="Binary")
+ fts_state = pulp.LpVariable.dicts("fts_state", (gws, ft_states_list), cat="Binary")
+
+ daux = (
+ pulp.LpVariable.dicts("daux", (list(set(teams.values())), gws), cat="Binary")
+ if settings.get("double_defense_pick")
+ else None
+ )
+ gw_with_tr = (
+ pulp.LpVariable.dicts("gw_with_tr", gws, cat="Binary")
+ if settings.get("transfer_itb_buffer") is not None
+ else None
+ )
+
+ # --- FT STATE LINKING ---
+ for w in gws:
+ prev_w = all_gws[all_gws.index(w) - 1]
+ prob += fts[prev_w] == pulp.lpSum(s * fts_state[w][s] for s in ft_states_list)
+ prob += pulp.lpSum(fts_state[w][s] for s in ft_states_list) == 1
+
+ gw_ft_value = {
+ w: pulp.lpSum(ft_state_value[s] * fts_state[w][s] for s in ft_states_list)
+ for w in gws
+ }
+
+ gw_ft_gain = {}
+ for i, w in enumerate(gws):
+ if i == 0:
+ # MATCH solver_original.py EXACTLY: difference from 0 for the first gw
+ gw_ft_gain[w] = gw_ft_value[w] - 0.0
+ else:
+ prev_w = gws[i - 1]
+ gw_ft_gain[w] = gw_ft_value[w] - gw_ft_value[prev_w]
+
+ # --- OBJECTIVE FUNCTION (Decayed to match original) ---
+ obj_parts = []
+ for i, w in enumerate(gws):
+ # Apply decay strictly to Hits, ITB, and FT Gains so the solver respects the horizon timing
+ decay_factor = pow(decay_base, i)
+ chip = chip_gws.get(w)
+
+ for p in players:
+ # Player EVs are already pre-decayed by solver_engine.py
+ ev = ev_matrix[p].get(w, 0)
+
+ cap_extra = 2.0 if chip == "tc" else 1.0
+ obj_parts.append(ev * lineup[p][w])
+ obj_parts.append(ev * cap_extra * captain[p][w])
+ obj_parts.append(ev * vice_weight * vice_captain[p][w])
+
+ bw = (
+ 1.0
+ if chip == "bb"
+ else (gk_bench_w if positions[p] == "G" else avg_of_bench_w)
+ )
+ obj_parts.append(ev * bw * (squad[p][w] - lineup[p][w]))
+
+ obj_parts.append(itb[w] * itb_value * decay_factor)
+ obj_parts.append(gw_ft_gain[w] * decay_factor)
+ obj_parts.append(-hits[w] * hit_cost * decay_factor)
+
+ # FT-use penalty ONLY applied outside of Wildcard/Free Hit
+ if ft_use_penalty != 0 and chip not in ("wc", "fh"):
+ for p in players:
+ obj_parts.append(-transfer_in[p][w] * ft_use_penalty * decay_factor)
+
+ prob += pulp.lpSum(obj_parts), "Total_EV_Objective"
+
+ # --- ADVANCED HORIZON-LEVEL CONSTRAINTS ---
+ if settings.get("hit_limit") is not None:
+ prob += pulp.lpSum(hits[w] for w in gws) <= settings["hit_limit"]
+
+ if settings.get("future_transfer_limit") is not None and len(gws) > 1:
+ prob += (
+ pulp.lpSum(
+ transfer_in[p][w]
+ for p in players
+ for w in gws[1:]
+ if chip_gws.get(w) not in ("wc", "fh")
+ )
+ <= settings["future_transfer_limit"]
+ )
+
+ if settings.get("no_gk_rotation_after") is not None:
+ target_gw = int(settings["no_gk_rotation_after"])
+ if target_gw in gws:
+ for p in players:
+ if positions[p] == "G":
+ for w in gws:
+ if w > target_gw and chip_gws.get(w) != "fh":
+ prob += lineup[p][w] >= lineup[p][target_gw]
+
+ # --- INITIAL CONDITIONS ---
+ for p in players:
+ prob += squad[p][all_gws[0]] == (1 if p in current_squad else 0)
+ prob += itb[all_gws[0]] == start_itb
+ prob += fts[all_gws[0]] == start_ft
+
+ # --- USER BANS & LOCKS ---
+ for p in players:
+ if p in banned_ids:
+ for w in gws:
+ prob += squad[p][w] == 0
+ if p in locked_ids:
+ for w in gws:
+ prob += squad[p][w] == 1
+
+ # --- TARGETED CHIP/PRICE CONSTRAINTS ---
+ for gw in settings.get("no_chip_gws", []):
+ if gw in chip_gws:
+ raise Exception(
+ f"Contradiction: user tried to play chip in GW {gw} but also assigned it to no_chip_gws!"
+ )
+
+ if settings.get("pick_prices"):
+ for pos, val in settings["pick_prices"].items():
+ if not val or pos not in ("G", "D", "M", "F"):
+ continue
+ price_pts = [float(x) for x in val.split(",")]
+ value_dict = {i: price_pts.count(i) for i in set(price_pts)}
+ for key, count in value_dict.items():
+ target_players = [
+ p
+ for p in players
+ if positions[p] == pos
+ and buy_prices[p] >= key - 0.2
+ and buy_prices[p] <= key + 0.2
+ ]
+ for w in gws:
+ prob += pulp.lpSum(squad[p][w] for p in target_players) >= count
+
+ # --- PER-GW CONSTRAINTS ---
+ for w in gws:
+ prev_w = all_gws[all_gws.index(w) - 1]
+ eff_prev = effective_prev[w]
+ chip = chip_gws.get(w)
+
+ prob += pulp.lpSum(squad[p][w] for p in players) == 15
+ prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "G") == 2
+ prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "D") == 5
+ prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "M") == 5
+ prob += pulp.lpSum(squad[p][w] for p in players if positions[p] == "F") == 3
+
+ prob += pulp.lpSum(lineup[p][w] for p in players) == 11
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "G") == 1
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "D") >= 3
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "D") <= 5
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "M") >= 2
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "M") <= 5
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "F") >= 1
+ prob += pulp.lpSum(lineup[p][w] for p in players if positions[p] == "F") <= 3
+ prob += pulp.lpSum(captain[p][w] for p in players) == 1
+ prob += pulp.lpSum(vice_captain[p][w] for p in players) == 1
+
+ for p in players:
+ prob += lineup[p][w] <= squad[p][w]
+ prob += captain[p][w] <= lineup[p][w]
+ prob += vice_captain[p][w] <= lineup[p][w]
+ prob += captain[p][w] + vice_captain[p][w] <= 1
+ prob += (
+ squad[p][w]
+ == squad[p][eff_prev] + transfer_in[p][w] - transfer_out[p][w]
+ )
+
+ for t in set(teams.values()):
+ prob += (
+ pulp.lpSum(squad[p][w] for p in players if teams[p] == t)
+ <= max_per_team
+ )
+
+ if gws.index(w) >= len(gws) - no_transfer_last_gws and chip not in ("wc", "fh"):
+ prob += pulp.lpSum(transfer_in[p][w] for p in players) == 0
+
+ prob += (
+ itb[eff_prev]
+ + pulp.lpSum(transfer_out[p][w] * sell_prices[p] for p in players)
+ - pulp.lpSum(transfer_in[p][w] * buy_prices[p] for p in players)
+ - pulp.lpSum(transfer_in[p][w] * itb_loss_per_transfer for p in players)
+ == itb[w]
+ )
+
+ gw_transfers = pulp.lpSum(transfer_in[p][w] for p in players)
+
+ # FT / Hits Engine
+ if chip in ("wc", "fh"):
+ prob += hits[w] == 0
+ raw_gw_ft = fts[prev_w]
+ else:
+ prob += hits[w] >= gw_transfers - fts[prev_w]
+ raw_gw_ft = fts[prev_w] - gw_transfers + 1
+
+ m = 25
+ prob += raw_gw_ft <= 0 + m * (1 - ft_below_lb[w])
+ prob += raw_gw_ft >= 1 - m * ft_below_lb[w]
+
+ prob += raw_gw_ft >= (max_ft + 1) - m * (1 - ft_above_ub[w])
+ prob += raw_gw_ft <= max_ft + m * ft_above_ub[w]
+
+ prob += fts[w] <= 1 + m * (1 - ft_below_lb[w])
+ prob += fts[w] >= 1 - m * (1 - ft_below_lb[w])
+
+ prob += fts[w] <= max_ft + m * (1 - ft_above_ub[w])
+ prob += fts[w] >= max_ft - m * (1 - ft_above_ub[w])
+
+ prob += fts[w] - raw_gw_ft <= m * ft_below_lb[w] + m * ft_above_ub[w]
+ prob += raw_gw_ft - fts[w] <= m * ft_below_lb[w] + m * ft_above_ub[w]
+
+ # --- ADVANCED GW-LEVEL CONSTRAINTS ---
+ for pid, tgw in settings.get("banned_next_gw", []):
+ if pid in players and (tgw == w or tgw is None):
+ prob += squad[pid][w] == 0
+
+ for pid, tgw in settings.get("locked_next_gw", []):
+ if pid in players and (tgw == w or tgw is None):
+ prob += squad[pid][w] == 1
+
+ for bt in settings.get("booked_transfers", []):
+ if bt.get("gw") == w:
+ if bt.get("transfer_in") in players:
+ prob += transfer_in[bt["transfer_in"]][w] == 1
+ if bt.get("transfer_out") in players:
+ prob += transfer_out[bt["transfer_out"]][w] == 1
+
+ if settings.get("only_booked_transfers") and w == gws[0]:
+ forced_ins = [
+ bt["transfer_in"]
+ for bt in settings.get("booked_transfers", [])
+ if bt.get("gw") == w and "transfer_in" in bt
+ ]
+ forced_outs = [
+ bt["transfer_out"]
+ for bt in settings.get("booked_transfers", [])
+ if bt.get("gw") == w and "transfer_out" in bt
+ ]
+ for p in players:
+ prob += transfer_in[p][w] == (1 if p in forced_ins else 0)
+ prob += transfer_out[p][w] == (1 if p in forced_outs else 0)
+
+ if settings.get("num_transfers") is not None and w == gws[0]:
+ prob += gw_transfers == int(settings["num_transfers"])
+
+ if (
+ settings.get("no_future_transfer")
+ and w > gws[0]
+ and chip not in ("wc", "fh")
+ ):
+ prob += gw_transfers == 0
+
+ if settings.get("weekly_hit_limit") is not None:
+ prob += hits[w] <= int(settings["weekly_hit_limit"])
+
+ if w in settings.get("no_transfer_gws", []):
+ prob += gw_transfers == 0
+
+ if (
+ settings.get("no_transfer_by_position")
+ and w > gws[0]
+ and chip not in ("wc", "fh")
+ ):
+ prob += (
+ pulp.lpSum(
+ transfer_in[p][w]
+ for p in players
+ if positions[p] in settings["no_transfer_by_position"]
+ )
+ == 0
+ )
+
+ mdpt = settings.get("max_defenders_per_team", 3)
+ if mdpt < 3:
+ for t in set(teams.values()):
+ prob += (
+ pulp.lpSum(
+ squad[p][w]
+ for p in players
+ if teams[p] == t and positions[p] in ("G", "D")
+ )
+ <= mdpt
+ )
+
+ if settings.get("double_defense_pick") and daux is not None:
+ for t in set(teams.values()):
+ team_defs = pulp.lpSum(
+ lineup[p][w]
+ for p in players
+ if teams[p] == t and positions[p] in ("G", "D")
+ )
+ prob += team_defs <= 3 * daux[t][w]
+ prob += team_defs >= 2 - 3 * (1 - daux[t][w])
+
+ if settings.get("transfer_itb_buffer") is not None and gw_with_tr is not None:
+ prob += 15 * gw_with_tr[w] >= gw_transfers
+ prob += gw_with_tr[w] <= gw_transfers
+ prob += itb[w] >= settings["transfer_itb_buffer"] * gw_with_tr[w]
+
+ for gw, ft_min in settings.get("force_ft_state_lb", []):
+ if w == gw:
+ prob += fts[w] >= ft_min
+ for gw, ft_max in settings.get("force_ft_state_ub", []):
+ if w == gw:
+ prob += fts[w] <= ft_max
+
+ if settings.get("no_trs_except_wc") and chip != "wc":
+ prob += gw_transfers == 0
+
+ # --- ITERATION LOOP ---
+ solutions = []
+ for i in range(iterations):
+ print(f"Solving Iteration {i + 1}...")
+
+ # THE FIX: Boot up the HiGHS engine instead of CBC
+ try:
+ solver = pulp.getSolver("HiGHS", msg=False, timeLimit=time_limit_sec)
+ except pulp.PulpSolverError:
+ print("HiGHS not found! Falling back to CBC...")
+ solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit_sec)
+
+ prob.solve(solver)
+
+ if pulp.LpStatus[prob.status] != "Optimal":
+ if i == 0:
+ raise Exception(
+ "Solver could not find an optimal solution! Check budget, bans, or locks."
+ )
+ else:
+ print(f"No more valid alternate paths found after {i} iterations.")
+ break
+
+ plan = []
+ pure_ev = 0.0
+ active_transfers = []
+
+ objective_score = None
+ try:
+ objective_score = round(float(pulp.value(prob.objective)), 4)
+ except (TypeError, ValueError):
+ objective_score = None
+
+ for w in gws:
+ prev_w = all_gws[all_gws.index(w) - 1]
+ chip = chip_gws.get(w)
+
+ # THE FIX 1: Safely extract binary variables using > 0.5 to prevent MILP floating-point drops
+ t_in_raw = [
+ p
+ for p in players
+ if transfer_in[p][w].varValue is not None
+ and transfer_in[p][w].varValue > 0.5
+ ]
+ t_out_raw = [
+ p
+ for p in players
+ if transfer_out[p][w].varValue is not None
+ and transfer_out[p][w].varValue > 0.5
+ ]
+
+ t_in = [p for p in t_in_raw if p not in t_out_raw]
+ t_out = [p for p in t_out_raw if p not in t_in_raw]
+
+ _pos_order = {"G": 0, "D": 1, "M": 2, "F": 3}
+ t_in.sort(key=lambda x: _pos_order.get(positions.get(x, "M"), 2))
+ t_out.sort(key=lambda x: _pos_order.get(positions.get(x, "M"), 2))
+
+ gw_lineup = [
+ p
+ for p in players
+ if lineup[p][w].varValue is not None and lineup[p][w].varValue > 0.5
+ ]
+ gw_bench_raw = [
+ p
+ for p in players
+ if squad[p][w].varValue is not None
+ and squad[p][w].varValue > 0.5
+ and (lineup[p][w].varValue is None or lineup[p][w].varValue < 0.5)
+ ]
+
+ # Safe extractions with fallbacks so indexing never crashes the loop
+ gw_cap_list = [
+ p
+ for p in players
+ if captain[p][w].varValue is not None and captain[p][w].varValue > 0.5
+ ]
+ gw_cap = (
+ gw_cap_list[0]
+ if gw_cap_list
+ else (gw_lineup[0] if gw_lineup else players[0])
+ )
+
+ gw_vice_list = [
+ p
+ for p in players
+ if vice_captain[p][w].varValue is not None
+ and vice_captain[p][w].varValue > 0.5
+ ]
+ gw_vice = (
+ gw_vice_list[0]
+ if gw_vice_list
+ else (gw_lineup[-1] if gw_lineup else players[-1])
+ )
+
+ gw_hits = int(round(hits[w].varValue or 0))
+
+ ft_at_start = int(round(fts[prev_w].varValue or 0))
+ transfers_made = len(t_in)
+ fts_free_used = min(transfers_made, ft_at_start)
+
+ gks_b = [p for p in gw_bench_raw if positions.get(p, "M") == "G"]
+ rest_b = sorted(
+ [p for p in gw_bench_raw if positions.get(p, "M") != "G"],
+ key=lambda x: raw_ev_matrix[x].get(w, 0),
+ reverse=True,
+ )
+ gw_bench_sorted = (gks_b + rest_b) if gks_b else rest_b
+
+ cap_mult = 3 if chip == "tc" else 2
+ gw_pure_ev = sum(
+ raw_ev_matrix[p].get(w, 0) * (cap_mult if p == gw_cap else 1)
+ for p in gw_lineup
+ )
+
+ if chip == "bb":
+ gw_pure_ev += sum(raw_ev_matrix[p].get(w, 0) for p in gw_bench_sorted)
+ else:
+ # THE FIX 2: Perfectly mimic React's horizonEV fractional bench math
+ of_bench_ws = [0.17, 0.05, 0.02]
+ of_idx = 0
+ for p in gw_bench_sorted:
+ if positions.get(p, "M") == "G":
+ gw_pure_ev += raw_ev_matrix[p].get(w, 0) * 0.04
+ else:
+ bw = of_bench_ws[of_idx] if of_idx < len(of_bench_ws) else 0.02
+ gw_pure_ev += raw_ev_matrix[p].get(w, 0) * bw
+ of_idx += 1
+
+ gw_pure_ev -= gw_hits * hit_cost
+ pure_ev += gw_pure_ev
+
+ # Only track transfers for the targeted GW based on the UI criteria
+ if "this_gw" in iteration_criteria and w == gws[0]:
+ if "in" in iteration_criteria:
+ for p in t_in:
+ active_transfers.append(transfer_in[p][w])
+ if "out" in iteration_criteria:
+ for p in t_out:
+ active_transfers.append(transfer_out[p][w])
+ elif "this_gw" not in iteration_criteria:
+ # Fallback: track all horizon transfers if criteria is empty or generic
+ for p in t_in:
+ active_transfers.append(transfer_in[p][w])
+ for p in t_out:
+ active_transfers.append(transfer_out[p][w])
+
+ plan.append(
+ {
+ "gw": w,
+ "chip": chip,
+ "transfers_in": t_in,
+ "transfers_out": t_out,
+ "lineup": gw_lineup,
+ "bench": gw_bench_sorted,
+ "captain": gw_cap,
+ "vice_captain": gw_vice,
+ "hits": gw_hits,
+ "itb": round(itb[w].varValue, 1),
+ "fts_remaining": int(fts[w].varValue),
+ "ft_at_start": ft_at_start,
+ "transfers_made": transfers_made,
+ "fts_free_used": fts_free_used,
+ }
+ )
+
+ solutions.append(
+ {
+ "id": i + 1,
+ "ev": round(pure_ev, 2),
+ "objective_score": objective_score,
+ "plan": plan,
+ "chips_used": chip_gws,
+ "horizon_gws": gws,
+ }
+ )
+
+ if len(active_transfers) > 0:
+ prob += (
+ pulp.lpSum(active_transfers) <= len(active_transfers) - iteration_diff
+ )
+ else:
+ prob += pulp.lpSum(transfer_in[p][w] for p in players for w in gws) >= 1
+
+ def sort_key(s):
+ obj = s.get("objective_score")
+ ev = s.get("ev") or 0
+ if obj is None:
+ return (-float(ev), -float(ev))
+ return (-float(obj), -float(ev))
+
+ solutions.sort(key=sort_key)
+ return {"status": "success", "solutions": solutions}
diff --git a/solver_engine.py b/solver_engine.py
new file mode 100644
index 0000000000000000000000000000000000000000..d22161c82f4709f3d1e048fa6542caf95462392b
--- /dev/null
+++ b/solver_engine.py
@@ -0,0 +1,185 @@
+from typing import Any
+
+
+def _norm_id_list(raw) -> list[int]:
+ if not raw:
+ return []
+ out = []
+ for x in raw:
+ if isinstance(x, dict) and "id" in x:
+ out.append(int(x["id"]))
+ else:
+ out.append(int(x))
+ return out
+
+
+def _norm_gw_list(raw) -> list[int]:
+ """Normalise a chip GW list to a list of ints, handling None gracefully."""
+ if not raw:
+ return []
+ return [int(g) for g in raw if g is not None]
+
+
+def prep_solver_data(payload_data: dict):
+ """
+ Translates the React JSON payload into the exact mathematical dictionaries
+ the MILP engine requires, including chips and advanced constraints.
+ """
+ print("Prepping data for the MILP Engine...")
+
+ horizon_gws = [int(g) for g in payload_data["horizon_gws"]]
+ raw_squad = payload_data["current_squad_ids"]
+ current_squad_ids = [
+ int(pid)
+ for pid in raw_squad
+ if isinstance(pid, (int, float)) or str(pid).isdigit()
+ ]
+
+ settings_payload: dict[str, Any] = dict(payload_data.get("settings") or {})
+ comp_payload = dict(payload_data.get("comprehensive_settings") or {})
+
+ # Start with an empty dictionary.
+ # Python no longer guesses defaults. It completely trusts the React payload.
+ settings = {}
+
+ # 1. Apply basic settings
+ settings.update({k: v for k, v in settings_payload.items() if v not in (None, "")})
+
+ # 2. Apply comprehensive settings (Filters out None AND empty strings from UI)
+ settings.update({k: v for k, v in comp_payload.items() if v not in (None, "")})
+
+ # --- BAN / LOCK ---
+ banned_ids = _norm_id_list(settings.get("banned") or settings.get("banned_ids"))
+ locked_ids = _norm_id_list(settings.get("locked") or settings.get("locked_ids"))
+ settings["banned_ids"] = banned_ids
+ settings["locked_ids"] = locked_ids
+
+ # --- SCALAR SETTINGS ---
+ settings["iterations"] = max(
+ 1,
+ min(5, int(settings.get("iterations", settings.get("num_iterations", 1)))),
+ )
+ settings["iteration_diff"] = int(settings.get("iteration_diff", 1))
+ settings["iteration_criteria"] = settings.get(
+ "iteration_criteria", "this_gw_transfer_in_out"
+ )
+ settings["hit_cost"] = int(settings.get("hit_cost", 4))
+ settings["max_ft"] = int(settings.get("max_ft", 5))
+ settings["itb_value"] = float(settings.get("itb_value", 0.08))
+ settings["time_limit_sec"] = int(settings.get("time_limit_sec", 3000))
+ settings["vice_weight"] = float(settings.get("vice_weight", 0.05))
+ settings["max_per_team"] = int(settings.get("max_per_team", 3))
+ settings["no_transfer_last_gws"] = int(settings.get("no_transfer_last_gws", 0))
+ settings["itb_loss_per_transfer"] = float(settings.get("itb_loss_per_transfer", 0))
+ settings["ft_value"] = float(
+ settings.get("ft_value_base", settings.get("ft_value", 0)) or 0
+ )
+ settings["ft_use_penalty"] = float(settings.get("ft_use_penalty", 0) or 0)
+ settings["future_transfer_limit"] = settings.get("future_transfer_limit")
+ if settings["future_transfer_limit"] is not None:
+ settings["future_transfer_limit"] = int(settings["future_transfer_limit"])
+ settings["hit_limit"] = settings.get("hit_limit")
+ if settings["hit_limit"] is not None:
+ settings["hit_limit"] = int(settings["hit_limit"])
+ settings["weekly_hit_limit"] = settings.get("weekly_hit_limit")
+ if settings["weekly_hit_limit"] is not None:
+ settings["weekly_hit_limit"] = int(settings["weekly_hit_limit"])
+ settings["no_transfer_gws"] = _norm_gw_list(settings.get("no_transfer_gws"))
+ settings["no_transfer_by_position"] = settings.get("no_transfer_by_position") or []
+ settings["no_trs_except_wc"] = bool(settings.get("no_trs_except_wc", False))
+ settings["max_defenders_per_team"] = int(settings.get("max_defenders_per_team", 3))
+ settings["double_defense_pick"] = bool(settings.get("double_defense_pick", False))
+ settings["transfer_itb_buffer"] = settings.get("transfer_itb_buffer")
+ if settings["transfer_itb_buffer"] is not None:
+ settings["transfer_itb_buffer"] = float(settings["transfer_itb_buffer"])
+ settings["no_gk_rotation_after"] = settings.get("no_gk_rotation_after")
+ settings["no_opposing_play"] = settings.get("no_opposing_play", False)
+ settings["opposing_play_group"] = settings.get("opposing_play_group", "all")
+ settings["opposing_play_penalty"] = float(
+ settings.get("opposing_play_penalty", 0.5)
+ )
+ settings["force_ft_state_lb"] = settings.get("force_ft_state_lb") or []
+ settings["force_ft_state_ub"] = settings.get("force_ft_state_ub") or []
+ settings["no_chip_gws"] = _norm_gw_list(settings.get("no_chip_gws"))
+
+ # Parse gw-specific lock/bans
+ def norm_temporal_list(raw):
+ if not raw:
+ return []
+ out = []
+ for x in raw:
+ if isinstance(x, list) and len(x) == 2:
+ out.append((int(x[0]), int(x[1])))
+ else:
+ out.append((int(x), None))
+ return out
+
+ settings["banned_next_gw"] = norm_temporal_list(settings.get("banned_next_gw"))
+ settings["locked_next_gw"] = norm_temporal_list(settings.get("locked_next_gw"))
+ settings["booked_transfers"] = settings.get("booked_transfers") or []
+ settings["only_booked_transfers"] = bool(
+ settings.get("only_booked_transfers", False)
+ )
+ settings["no_future_transfer"] = bool(settings.get("no_future_transfer", False))
+ settings["num_transfers"] = settings.get("num_transfers")
+ settings["pick_prices"] = settings.get("pick_prices", {})
+
+ # --- CHIP GW LISTS ---
+ settings["use_wc"] = _norm_gw_list(settings.get("use_wc"))
+ settings["use_fh"] = _norm_gw_list(settings.get("use_fh"))
+ settings["use_bb"] = _norm_gw_list(settings.get("use_bb"))
+ settings["use_tc"] = _norm_gw_list(settings.get("use_tc"))
+
+ # --- BENCH WEIGHTS (dict with string keys "0"-"3") ---
+ raw_bw = settings.get("bench_weights")
+ if raw_bw and isinstance(raw_bw, dict):
+ settings["bench_weights"] = {str(k): float(v) for k, v in raw_bw.items()}
+ else:
+ settings["bench_weights"] = {"0": 0.03, "1": 0.18, "2": 0.06, "3": 0.002}
+
+ # --- FT VALUE LIST (dict with string keys "2"-"5") ---
+ raw_ftvl = settings.get("ft_value_list")
+ if raw_ftvl and isinstance(raw_ftvl, dict):
+ settings["ft_value_list"] = {str(k): float(v) for k, v in raw_ftvl.items()}
+ else:
+ settings["ft_value_list"] = {}
+
+ # --- DECAY ---
+ decay = float(settings.get("decay_base", settings.get("decay", 1.0)) or 1.0)
+
+ # --- BUILD PLAYER DICTS ---
+ buy_prices: dict[int, float] = {}
+ sell_prices: dict[int, float] = {}
+ positions: dict[int, str] = {}
+ teams: dict[int, str] = {}
+ ev_matrix: dict[int, dict[int, float]] = {}
+ raw_ev_matrix: dict[int, dict[int, float]] = {}
+
+ for idx, gw in enumerate(horizon_gws):
+ decay_factor = decay**idx if decay != 1.0 else 1.0
+ for p in payload_data["market_players"]:
+ pid = int(p["id"])
+ if pid not in buy_prices:
+ positions[pid] = p["pos"]
+ teams[pid] = p["team"]
+ buy_prices[pid] = float(p["now_cost"])
+ sell_prices[pid] = float(p.get("sell_price", p["now_cost"]))
+ ev_matrix[pid] = {}
+ raw_ev_matrix[pid] = {}
+ raw_ev = p["evs"].get(gw, p["evs"].get(str(gw), 0))
+ ev_matrix[pid][gw] = float(raw_ev) * decay_factor
+ raw_ev_matrix[pid][gw] = float(raw_ev)
+
+ return {
+ "players": list(buy_prices.keys()),
+ "gws": horizon_gws,
+ "buy_prices": buy_prices,
+ "sell_prices": sell_prices,
+ "positions": positions,
+ "teams": teams,
+ "ev_matrix": ev_matrix,
+ "current_squad": current_squad_ids,
+ "itb": float(payload_data["in_the_bank"]),
+ "ft": int(payload_data["free_transfers"]),
+ "settings": settings,
+ }
diff --git a/statistical_weighted_baselines.csv b/statistical_weighted_baselines.csv
new file mode 100644
index 0000000000000000000000000000000000000000..b4328a97672dc2ce795736eb87b7aaf05a0f4d22
--- /dev/null
+++ b/statistical_weighted_baselines.csv
@@ -0,0 +1,437 @@
+player_name,baseline_xMins,baseline_xG_p90,baseline_xA_p90,baseline_CBIT_p90,baseline_CBITR_p90,baseline_yc_p90,baseline_rc_p90,baseline_Neutral_BPS_p90,baseline_Def_BPS_p90,baseline_Mid_BPS_p90,baseline_Fwd_BPS_p90,baseline_bps_floor_p90
+James Milner,39.6714,0.0613,0.0664,3.518,6.9378,0.212,0.0,5.7718,5.7718,5.7718,5.7718,2.7718
+Séamus Coleman,57.3963,0.0055,0.0786,5.6685,9.7906,0.0141,0.0,8.9059,8.9059,8.9059,8.9059,5.9059
+Ashley Barnes,21.6698,0.1182,0.0141,1.3284,3.1267,0.1613,0.0,1.732,1.732,1.732,1.732,-1.268
+Danny Welbeck,62.1536,0.3936,0.0611,2.6474,4.5537,0.2021,0.0,7.302,7.302,7.302,7.302,1.302
+Jordan Henderson,63.3945,0.0225,0.1382,3.7085,8.0825,0.1727,0.0,9.7514,9.7514,9.7514,9.7514,3.7514
+Nathaniel Clyne,58.9565,0.0532,0.084,4.7175,6.8736,0.078,0.0,6.2698,6.2698,6.2698,6.2698,3.2698
+Kyle Walker,84.584,0.0066,0.0587,5.3085,9.8583,0.2381,0.0,9.6246,9.6246,9.6246,9.6246,3.6246
+Adam Smith,47.3125,0.0085,0.0298,4.8414,7.5102,0.2777,0.0,5.4291,5.4291,5.4291,5.4291,2.4291
+Pascal Groß,71.4129,0.0866,0.2516,3.9695,8.0327,0.1435,0.0008,11.74,11.74,11.74,11.74,5.74
+Chris Wood,69.913,0.444,0.0275,1.1135,2.4636,0.0068,0.0,6.2547,6.2547,6.2547,6.2547,0.2547
+Idrissa Gana Gueye,83.974,0.0613,0.0509,5.3431,10.7496,0.1603,0.0329,10.4409,10.4409,10.4409,10.4409,4.4409
+Tom Cairney,27.1217,0.1147,0.075,2.4975,7.8839,0.3763,0.0404,6.7132,6.7132,6.7132,6.7132,3.7132
+Callum Wilson,38.9696,0.428,0.0288,1.3382,3.3843,0.0261,0.0,2.9714,2.9714,2.9714,2.9714,-0.0286
+Daniel Burn,80.3214,0.033,0.0203,8.3726,11.4704,0.3772,0.0,9.4923,9.4923,9.4923,9.4923,3.4923
+Fabian Schär,70.5607,0.0891,0.0449,7.9447,11.6652,0.1325,0.0069,10.1465,10.1465,10.1465,10.1465,4.1465
+Kieran Trippier,77.4041,0.0074,0.1299,5.1416,9.3718,0.1598,0.0,12.1454,12.1454,12.1454,12.1454,6.1454
+Lewis Dunk,87.7794,0.0429,0.0274,6.2588,10.7112,0.3055,0.0023,9.9572,9.9572,9.9572,9.9572,3.9572
+Wataru Endo,19.0795,0.0082,0.0259,3.2931,5.2888,0.0224,0.0,4.4307,4.4307,4.4307,4.4307,1.4307
+Granit Xhaka,84.6314,0.0314,0.1113,5.0263,10.2763,0.1623,0.0,10.3586,10.3586,10.3586,10.3586,4.3586
+Casemiro,72.5137,0.1724,0.1172,7.2079,12.5625,0.3861,0.002,11.0407,11.0407,11.0407,11.0407,5.0407
+Virgil van Dijk,90.0,0.0795,0.0337,8.4305,11.1223,0.1263,0.0021,10.804,10.804,10.804,10.804,4.804
+Lucas Digne,61.7184,0.0358,0.1357,5.8598,9.3683,0.1455,0.0,11.7889,11.7889,11.7889,11.7889,5.7889
+Ross Barkley,28.1208,0.1515,0.184,4.767,9.9865,0.0909,0.0,8.3527,8.3527,8.3527,8.3527,5.3527
+Mateo Kovacic,68.4976,0.0705,0.1114,4.602,8.8711,0.2021,0.0274,11.6324,11.6324,11.6324,11.6324,5.6324
+Matt Doherty,70.5783,0.0194,0.0314,5.5553,8.7791,0.3211,0.0,8.8949,8.8949,8.8949,8.8949,2.8949
+James Tarkowski,89.8182,0.0684,0.051,9.1775,11.9313,0.1796,0.0,11.0154,11.0154,11.0154,11.0154,5.0154
+Willy Boly,70.8854,0.074,0.0392,8.6866,13.4641,0.1853,0.0,12.9516,12.9516,12.9516,12.9516,6.9516
+Harry Maguire,66.7005,0.0713,0.0166,7.2094,10.7458,0.249,0.0506,9.5376,9.5376,9.5376,9.5376,3.5376
+Victor Nilsson Lindelöf,47.1313,0.0058,0.0088,4.885,7.4236,0.0124,0.0,6.0633,6.0633,6.0633,6.0633,3.0633
+Emil Krafth,31.185,0.008,0.0198,2.9578,5.4403,0.2968,0.0,5.3565,5.3565,5.3565,5.3565,2.3565
+John Stones,50.3134,0.0138,0.0203,3.5864,7.3437,0.0527,0.0,6.4839,6.4839,6.4839,6.4839,3.4839
+Christian Nørgaard,82.9266,0.1294,0.053,6.3074,12.6303,0.2601,0.0231,12.0969,12.0969,12.0969,12.0969,6.0969
+Ben Davies,76.3555,0.0711,0.0427,6.6989,9.8846,0.2962,0.0,10.5317,10.5317,10.5317,10.5317,4.5317
+James Ward-Prowse,65.6126,0.0427,0.1546,3.8792,7.2895,0.1521,0.0,11.7213,11.7213,11.7213,11.7213,5.7213
+Michael Keane,78.5698,0.0813,0.0067,9.3238,12.4687,0.127,0.0285,11.1349,11.1349,11.1349,11.1349,5.1349
+Raul Jiménez,66.1636,0.4172,0.0495,3.6303,6.032,0.1953,0.0048,6.4041,6.4041,6.4041,6.4041,0.4041
+Mohamed Salah,83.5283,0.3841,0.1955,1.7046,4.5425,0.0468,0.0,8.3554,8.3554,8.3554,8.3554,2.3554
+Will Hughes,50.0785,0.0613,0.1028,4.6142,8.7434,0.4291,0.0,5.8214,5.8214,5.8214,5.8214,2.8214
+Bertrand Traoré,53.9168,0.1412,0.0713,2.6317,5.2078,0.1344,0.0,5.7778,5.7778,5.7778,5.7778,2.7778
+Adam Webster,65.0944,0.0332,0.0242,6.582,10.604,0.0951,0.0,10.3366,10.3366,10.3366,10.3366,4.3366
+Joël Veltman,49.134,0.0764,0.0275,7.5607,11.1191,0.3037,0.0,7.3205,7.3205,7.3205,7.3205,4.3205
+Jack Grealish,75.5761,0.1228,0.2028,2.9445,7.2046,0.3559,0.0,10.6709,10.6709,10.6709,10.6709,4.6709
+Leandro Trossard,65.1074,0.2545,0.149,3.1624,6.7663,0.0767,0.0,9.3124,9.3124,9.3124,9.3124,3.3124
+John McGinn,71.4173,0.1195,0.1144,2.578,6.3241,0.1742,0.0003,8.3007,8.3007,8.3007,8.3007,2.3007
+Andrew Robertson,44.9528,0.044,0.1382,4.5253,8.2566,0.0351,0.0081,8.7264,8.7264,8.7264,8.7264,5.7264
+Luke Shaw,80.1451,0.0144,0.0849,4.9434,7.966,0.2004,0.0,8.6246,8.6246,8.6246,8.6246,2.6246
+Sam Byram,41.7403,0.058,0.0838,6.1084,10.1179,0.2161,0.0,5.1704,5.1704,5.1704,5.1704,2.1704
+Solly March,80.0836,0.3138,0.1364,5.0018,10.0345,0.0282,0.0,13.5614,13.5614,13.5614,13.5614,7.5614
+Nathan Aké,39.2062,0.0467,0.0534,5.1295,7.5916,0.1343,0.0,6.2377,6.2377,6.2377,6.2377,3.2377
+Tyrone Mings,74.8588,0.0851,0.0295,6.7748,9.6637,0.0329,0.0,10.0149,10.0149,10.0149,10.0149,4.0149
+Bruno Fernandes,86.6398,0.2737,0.3544,4.3505,9.4371,0.1269,0.0076,13.7658,13.7658,13.7658,13.7658,7.7658
+Ryan Christie,39.2327,0.1586,0.0814,5.6652,11.1537,0.3358,0.0,8.1985,8.1985,8.1985,8.1985,5.1985
+Enes Ünal,20.0751,0.3639,0.0478,2.1762,5.2773,0.294,0.0,2.3952,2.3952,2.3952,2.3952,-0.6048
+Timothy Castagne,55.0163,0.0558,0.0434,7.096,9.9304,0.1753,0.0,7.7746,7.7746,7.7746,7.7746,4.7746
+Youri Tielemans,74.8502,0.0659,0.1635,4.5134,8.8142,0.0234,0.0,10.4267,10.4267,10.4267,10.4267,4.4267
+Jefferson Lerma,54.2134,0.0575,0.0614,6.0078,10.74,0.3623,0.0,6.8811,6.8811,6.8811,6.8811,3.8811
+Kenny Tete,81.4565,0.0556,0.0612,8.2041,12.2687,0.1494,0.0,10.4875,10.4875,10.4875,10.4875,4.4875
+Luke O'Nien,86.9746,0.0531,0.0321,6.0196,10.0835,0.2469,0.0021,9.0739,9.0739,9.0739,9.0739,3.0739
+Connor Roberts,82.5452,0.0492,0.0838,3.6919,6.5497,0.1638,0.002,8.9503,8.9503,8.9503,8.9503,2.9503
+Bernardo Silva,75.8748,0.0787,0.1446,3.2829,7.2889,0.2977,0.0,9.4714,9.4714,9.4714,9.4714,3.4714
+James Maddison,60.9645,0.2712,0.2376,2.7141,5.5842,0.228,0.0,11.4599,11.4599,11.4599,11.4599,5.4599
+Adama Traoré,20.2039,0.0836,0.0622,1.2972,3.5844,0.0289,0.0,4.6235,4.6235,4.6235,4.6235,1.6235
+Harry Wilson,74.2824,0.2016,0.1416,3.6154,6.6863,0.1962,0.0,11.472,11.472,11.472,11.472,5.472
+Harrison Reed,52.776,0.0323,0.087,4.1656,8.7985,0.3522,0.0,6.8598,6.8598,6.8598,6.8598,3.8598
+Joachim Andersen,88.3484,0.0449,0.0483,9.4548,13.4665,0.2456,0.0076,11.2911,11.2911,11.2911,11.2911,5.2911
+Adam Armstrong,71.2089,0.3795,0.0821,2.1203,3.7849,0.1738,0.0,6.7006,6.7006,6.7006,6.7006,0.7006
+Jacob Murphy,50.5381,0.217,0.1878,2.9053,6.0014,0.0868,0.0,8.1246,8.1246,8.1246,8.1246,5.1246
+Josh Laurent,56.9545,0.0591,0.0513,5.099,8.6116,0.3508,0.0502,4.46,4.46,4.46,4.46,1.46
+Jarrod Bowen,89.3527,0.2134,0.1469,3.5336,7.5841,0.0945,0.0,9.9311,9.9311,9.9311,9.9311,3.9311
+João Palhinha,64.616,0.0731,0.0347,7.5809,10.9219,0.313,0.0302,8.0653,8.0653,8.0653,8.0653,2.0653
+Tomás Soucek,59.1118,0.2141,0.0259,5.8662,8.4173,0.1439,0.038,5.3244,5.3244,5.3244,5.3244,2.3244
+Martin Ødegaard,56.9913,0.115,0.2255,2.594,6.4656,0.034,0.0,8.0491,8.0491,8.0491,8.0491,5.0491
+Joelinton,72.0082,0.1271,0.0651,5.0965,10.1601,0.3804,0.0,9.0571,9.0571,9.0571,9.0571,3.0571
+Ollie Watkins,75.7704,0.4218,0.0539,2.0206,3.5696,0.1062,0.0,5.6532,5.6532,5.6532,5.6532,-0.3468
+Sander Berge,82.1893,0.0428,0.0368,4.2055,8.5859,0.1925,0.0,8.2436,8.2436,8.2436,8.2436,2.2436
+Kristoffer Ajer,66.7083,0.0353,0.04,6.3133,8.6108,0.1157,0.0,8.7748,8.7748,8.7748,8.7748,2.7748
+Sasa Lukic,64.1087,0.1076,0.0818,5.1158,8.8396,0.4202,0.0,8.8241,8.8241,8.8241,8.8241,2.8241
+Lewis Cook,58.0725,0.0113,0.0667,6.1148,11.7456,0.1381,0.078,8.6,8.6,8.6,8.6,5.6
+Rico Henry,56.6783,0.0116,0.0717,3.9376,8.3478,0.1542,0.0,6.015,6.015,6.015,6.015,3.015
+Joseph Gomez,34.4239,0.0211,0.0668,4.4718,7.1538,0.2659,0.0,5.9429,5.9429,5.9429,5.9429,2.9429
+Mikel Merino,48.7314,0.2538,0.112,4.9077,9.6664,0.1386,0.0,4.5583,4.5583,4.5583,4.5583,1.5583
+Gabriel Jesus,28.8163,0.4841,0.0736,3.3668,7.8254,0.481,0.0,4.8218,4.8218,4.8218,4.8218,1.8218
+Dominic Solanke,67.1787,0.2824,0.0334,3.0413,5.535,0.0866,0.0,7.4527,7.4527,7.4527,7.4527,1.4527
+Reinildo,75.4526,0.0071,0.0282,6.1965,10.1647,0.3652,0.0441,8.448,8.448,8.448,8.448,2.448
+Nordi Mukiele,83.0957,0.0791,0.0713,8.1114,12.236,0.1967,0.0,10.3141,10.3141,10.3141,10.3141,4.3141
+Alex Iwobi,84.8508,0.0741,0.1698,2.6644,7.2014,0.1178,0.0,11.5063,11.5063,11.5063,11.5063,5.5063
+Emiliano Buendía,45.6277,0.1506,0.0835,3.3038,7.5885,0.2588,0.0,4.4589,4.4589,4.4589,4.4589,1.4589
+Dominic Calvert-Lewin,73.8994,0.4326,0.0421,1.9821,3.592,0.0658,0.0,4.6187,4.6187,4.6187,4.6187,-1.3813
+Olivier Boscagli,59.0087,0.0184,0.0808,7.8905,14.4618,0.3312,0.0,7.742,7.742,7.742,7.742,4.742
+Rúben Dias,82.2657,0.0355,0.0345,6.0398,9.2057,0.128,0.0,9.8621,9.8621,9.8621,9.8621,3.8621
+Hee-Chan Hwang,54.2547,0.1284,0.0578,2.1253,4.5528,0.1735,0.0,4.1966,4.1966,4.1966,4.1966,1.1966
+Rodrigo Bentancur,69.8144,0.0515,0.0556,6.2548,12.6439,0.3665,0.0,10.7436,10.7436,10.7436,10.7436,4.7436
+Daichi Kamada,70.6573,0.084,0.1183,4.6389,9.5517,0.1828,0.0108,9.0194,9.0194,9.0194,9.0194,3.0194
+Borna Sosa,60.6152,0.014,0.1147,4.6674,8.2582,0.0391,0.0,10.4032,10.4032,10.4032,10.4032,4.4032
+Josh Cullen,84.6731,0.0493,0.0874,5.3521,10.0416,0.127,0.0,8.9813,8.9813,8.9813,8.9813,2.9813
+Marcus Edwards,48.3646,0.1306,0.1673,2.047,5.4119,0.051,0.0006,5.8621,5.8621,5.8621,5.8621,2.8621
+Taiwo Awoniyi,21.427,0.5817,0.0999,2.5243,5.6836,0.243,0.0,2.9031,2.9031,2.9031,2.9031,-0.0969
+Mathias Jensen,54.2856,0.0768,0.1859,3.7352,7.8106,0.1511,0.0,7.4801,7.4801,7.4801,7.4801,4.4801
+Declan Rice,85.4853,0.0975,0.1919,5.2627,10.7477,0.0866,0.0,12.7709,12.7709,12.7709,12.7709,6.7709
+Richarlison,54.6488,0.4494,0.056,3.8676,6.1779,0.2491,0.0,4.2884,4.2884,4.2884,4.2884,1.2884
+Jacob Bruun Larsen,40.5425,0.1035,0.0802,2.2251,4.1629,0.007,0.0,5.4878,5.4878,5.4878,5.4878,2.4878
+Antonee Robinson,72.0543,0.0192,0.1097,7.6283,12.6243,0.2511,0.0,12.8392,12.8392,12.8392,12.8392,6.8392
+Viktor Gyökeres,68.0545,0.53,0.1233,1.7557,4.0739,0.1384,0.0,7.79,7.79,7.79,7.79,1.79
+Yoane Wissa,39.4031,0.4432,0.0942,1.3103,3.9643,0.2066,0.0,2.9307,2.9307,2.9307,2.9307,-0.0693
+Leon Bailey,34.3641,0.0848,0.1621,1.8705,5.4582,0.1826,0.0,6.0711,6.0711,6.0711,6.0711,3.0711
+David Brooks,40.8249,0.2889,0.1808,2.9367,6.3385,0.4421,0.0,7.0783,7.0783,7.0783,7.0783,4.0783
+Rodri,71.416,0.0749,0.1565,5.5869,12.132,0.1594,0.0066,11.4756,11.4756,11.4756,11.4756,5.4756
+Ola Aina,87.771,0.0214,0.0578,6.4386,11.2018,0.1812,0.0,10.9291,10.9291,10.9291,10.9291,4.9291
+Tosin Adarabioyo,51.4545,0.0242,0.0375,9.2791,11.3754,0.1905,0.0,7.2733,7.2733,7.2733,7.2733,4.2733
+Vitaly Janelt,58.8412,0.059,0.1183,5.5421,10.6014,0.3418,0.0,8.0211,8.0211,8.0211,8.0211,5.0211
+Samuel Chukwueze,44.702,0.1419,0.2078,2.1638,5.5374,0.003,0.0,7.0758,7.0758,7.0758,7.0758,4.0758
+Alexander Isak,54.7967,0.44,0.0441,2.2524,4.0183,0.0136,0.0,2.6237,2.6237,2.6237,2.6237,-0.3763
+Axel Tuanzebe,83.8557,0.0357,0.0085,6.4851,8.883,0.1523,0.0,8.6331,8.6331,8.6331,8.6331,2.6331
+Issa Diop,59.8571,0.0117,0.0072,6.4771,8.9997,0.244,0.0,6.0656,6.0656,6.0656,6.0656,3.0656
+Axel Disasi,86.8194,0.1254,0.0172,4.6822,7.4234,0.0654,0.0,8.6204,8.6204,8.6204,8.6204,2.6204
+Jean-Philippe Mateta,79.7908,0.4896,0.0596,2.6123,5.0603,0.0934,0.0,6.1259,6.1259,6.1259,6.1259,0.1259
+James Justin,62.0652,0.0441,0.077,6.0186,8.5668,0.1372,0.0,9.7366,9.7366,9.7366,9.7366,3.7366
+Ezri Konsa,88.8325,0.0397,0.0197,5.1368,8.2151,0.0248,0.0228,9.4391,9.4391,9.4391,9.4391,3.4391
+Ethan Pinnock,81.1245,0.0211,0.021,10.0153,13.0793,0.2137,0.0,11.0029,11.0029,11.0029,11.0029,5.0029
+Joe Worrall,43.3755,0.0332,0.0254,9.5104,11.7861,0.0839,0.0072,7.7185,7.7185,7.7185,7.7185,4.7185
+Joe Rodon,88.0996,0.0397,0.0213,7.6086,10.6963,0.0631,0.0015,9.9441,9.9441,9.9441,9.9441,3.9441
+Daniel James,33.1884,0.193,0.1562,2.4656,5.1513,0.0408,0.0,6.4574,6.4574,6.4574,6.4574,3.4574
+Konstantinos Mavropanos,76.8195,0.0899,0.0197,9.6606,13.1147,0.1431,0.0,11.1964,11.1964,11.1964,11.1964,5.1964
+Yves Bissouma,56.8711,0.0514,0.0206,6.2676,10.7123,0.3919,0.0015,6.508,6.508,6.508,6.508,3.508
+Matty Cash,85.9965,0.0419,0.0846,5.6019,8.6916,0.2683,0.0,10.1969,10.1969,10.1969,10.1969,4.1969
+Tyler Adams,77.7528,0.0398,0.0267,6.3601,11.3884,0.3856,0.0,9.4939,9.4939,9.4939,9.4939,3.4939
+Kyle Walker-Peters,57.6847,0.0395,0.0382,5.4612,9.1859,0.1506,0.0,6.3501,6.3501,6.3501,6.3501,3.3501
+Erling Haaland,84.0367,0.7391,0.0748,2.3317,3.507,0.0464,0.0,7.7622,7.7622,7.7622,7.7622,1.7622
+Gabriel Gudmundsson,78.7697,0.0381,0.0781,5.3023,9.0553,0.1881,0.0,9.2921,9.2921,9.2921,9.2921,3.2921
+Reiss Nelson,35.3623,0.1055,0.0782,1.8347,4.6282,0.1015,0.0,7.9878,7.9878,7.9878,7.9878,4.9878
+Tammy Abraham,53.7219,0.4622,0.1129,2.257,3.4025,0.1158,0.0,2.1343,2.1343,2.1343,2.1343,-0.8657
+Kai Havertz,48.8885,0.1738,0.0398,1.8911,3.5802,0.0665,0.0,4.3015,4.3015,4.3015,4.3015,1.3015
+Mason Mount,44.3782,0.2043,0.1028,2.582,6.1404,0.0426,0.0,5.3445,5.3445,5.3445,5.3445,2.3445
+Diogo Dalot,78.1651,0.0894,0.0705,5.5066,10.1093,0.3011,0.0,10.5698,10.5698,10.5698,10.5698,4.5698
+Omar Alderete,87.3022,0.0498,0.0305,9.3049,12.7297,0.2404,0.0032,10.908,10.908,10.908,10.908,4.908
+Kevin Danso,59.0227,0.0334,0.021,10.523,14.3474,0.3133,0.0007,7.5376,7.5376,7.5376,7.5376,4.5376
+Joshua Dasilva,33.3575,0.0614,0.0583,1.4602,3.8804,0.0382,0.0,5.4362,5.4362,5.4362,5.4362,2.4362
+Aaron Wan-Bissaka,86.7635,0.0117,0.0504,6.9512,10.4543,0.1421,0.0,11.6128,11.6128,11.6128,11.6128,5.6128
+Harvey Barnes,54.9657,0.327,0.1551,2.3325,5.2094,0.0484,0.0,6.1454,6.1454,6.1454,6.1454,3.1454
+Nikola Milenkovic,90.0,0.0452,0.0168,7.3226,10.155,0.1701,0.0,9.6987,9.6987,9.6987,9.6987,3.6987
+Igor Julio,73.1054,0.0098,0.0203,4.354,8.8904,0.3295,0.0,9.6899,9.6899,9.6899,9.6899,3.6899
+Jean-Ricner Bellegarde,49.3081,0.0684,0.0887,3.5704,6.2098,0.2374,0.0119,5.7131,5.7131,5.7131,5.7131,2.7131
+Matthijs de Ligt,87.8321,0.1088,0.0449,7.5112,10.7328,0.0564,0.0,10.6622,10.6622,10.6622,10.6622,4.6622
+Ismaïla Sarr,84.5332,0.3358,0.0834,2.6434,5.8199,0.1026,0.0,7.3681,7.3681,7.3681,7.3681,1.3681
+Ryan Sessegnon,64.1123,0.0688,0.082,6.5739,9.9008,0.0353,0.0,9.0202,9.0202,9.0202,9.0202,3.0202
+Ferdi Kadioglu,80.6014,0.0508,0.0823,4.7036,8.1711,0.1332,0.0,9.8008,9.8008,9.8008,9.8008,3.8008
+Noussair Mazraoui,45.6921,0.0146,0.0445,5.5754,8.8567,0.0914,0.0,7.0279,7.0279,7.0279,7.0279,4.0279
+Ryan Yates,29.1609,0.0601,0.0319,5.544,8.4903,0.2789,0.0,4.6023,4.6023,4.6023,4.6023,1.6023
+Ben White,60.2477,0.0762,0.1069,5.9675,9.6356,0.0576,0.0,10.3367,10.3367,10.3367,10.3367,4.3367
+Ethan Ampadu,87.7042,0.056,0.0648,6.4113,11.9863,0.2867,0.0,8.9965,8.9965,8.9965,8.9965,2.9965
+Federico Chiesa,19.2337,0.1404,0.0781,2.1516,3.23,0.2043,0.0,3.8858,3.8858,3.8858,3.8858,0.8858
+Marcos Senesi,85.6027,0.0547,0.1105,10.0739,14.7792,0.3196,0.0,12.0188,12.0188,12.0188,12.0188,6.0188
+Douglas Luiz,47.5641,0.0702,0.1472,3.9758,9.0541,0.1881,0.0001,7.7734,7.7734,7.7734,7.7734,4.7734
+Cristian Romero,81.5152,0.0751,0.0585,8.1894,12.9894,0.4079,0.0362,10.8331,10.8331,10.8331,10.8331,4.8331
+Morgan Gibbs-White,85.0477,0.2302,0.0971,2.5083,5.7088,0.1275,0.0,9.6189,9.6189,9.6189,9.6189,3.6189
+Marcus Tavernier,77.1869,0.2595,0.1394,3.7547,8.4673,0.2205,0.0,10.4859,10.4859,10.4859,10.4859,4.4859
+Jayden Bogle,83.6295,0.0838,0.0748,5.5603,9.7556,0.2476,0.0,8.5493,8.5493,8.5493,8.5493,2.5493
+Joël Piroe,66.4963,0.3835,0.086,2.2183,4.5485,0.0724,0.0015,7.2423,7.2423,7.2423,7.2423,1.2423
+Pau Torres,82.0269,0.0342,0.023,5.2792,9.4586,0.0298,0.0,9.5874,9.5874,9.5874,9.5874,3.5874
+Florentino,64.4552,0.0417,0.0289,7.7288,12.5273,0.2199,0.0,8.0245,8.0245,8.0245,8.0245,2.0245
+Trevoh Chalobah,86.8209,0.0501,0.031,7.8532,11.6842,0.1189,0.0238,10.7884,10.7884,10.7884,10.7884,4.7884
+Justin Kluivert,53.4828,0.2143,0.1129,1.9868,5.661,0.3039,0.0,5.8771,5.8771,5.8771,5.8771,2.8771
+Gabriel,86.3562,0.0879,0.0612,7.4316,9.656,0.1304,0.0,9.7852,9.7852,9.7852,9.7852,3.7852
+Ibrahim Sangaré,72.8384,0.0455,0.0691,5.8889,10.9005,0.2705,0.0,8.8789,8.8789,8.8789,8.8789,2.8789
+Pascal Struijk,86.0099,0.099,0.0281,8.1066,12.1234,0.1251,0.0,10.0097,10.0097,10.0097,10.0097,4.0097
+Ladislav Krejcí,85.6093,0.0647,0.0434,5.5143,8.0472,0.2232,0.0,8.4131,8.4131,8.4131,8.4131,2.4131
+Cody Gakpo,71.7538,0.2982,0.1759,3.125,6.3458,0.1215,0.0,9.8509,9.8509,9.8509,9.8509,3.8509
+Reece James,68.3671,0.0438,0.1363,5.2124,9.7272,0.1909,0.0194,11.1369,11.1369,11.1369,11.1369,5.1369
+Phil Foden,68.9227,0.2373,0.2173,2.9268,6.5894,0.1746,0.0,12.2358,12.2358,12.2358,12.2358,6.2358
+Boubacar Kamara,77.1563,0.0271,0.0496,4.9313,9.1228,0.2124,0.0003,9.1448,9.1448,9.1448,9.1448,3.1448
+Eberechi Eze,60.2754,0.253,0.132,3.0786,6.9162,0.0519,0.0,11.053,11.053,11.053,11.053,5.053
+Sean Longstaff,52.4896,0.0751,0.0945,4.8143,8.1738,0.1177,0.0,7.4215,7.4215,7.4215,7.4215,4.4215
+Ibrahima Konaté,85.3651,0.0446,0.0329,7.8636,11.4578,0.215,0.0,10.0037,10.0037,10.0037,10.0037,4.0037
+Jørgen Strand Larsen,67.8845,0.2333,0.0315,2.1191,3.5862,0.1751,0.0,5.034,5.034,5.034,5.034,-0.966
+Jorge Cuenca,63.7189,0.009,0.0373,9.1543,12.6348,0.3462,0.0,10.6979,10.6979,10.6979,10.6979,4.6979
+Valentín Castellanos,72.4277,0.3205,0.0805,2.6593,4.5927,0.2502,0.0085,6.5152,6.5152,6.5152,6.5152,0.5152
+Randal Kolo Muani,54.2872,0.2001,0.0793,2.4276,4.9604,0.112,0.0,2.8491,2.8491,2.8491,2.8491,-0.1509
+Nicolás Dominguez,48.3402,0.0661,0.0391,4.8677,8.057,0.2303,0.0,6.1064,6.1064,6.1064,6.1064,3.1064
+Hjalmar Ekdal,79.9099,0.0176,0.0173,9.2071,12.1149,0.066,0.0,10.2096,10.2096,10.2096,10.2096,4.2096
+Alexis Mac Allister,73.0199,0.1151,0.09,4.5682,8.7472,0.2046,0.0026,10.1764,10.1764,10.1764,10.1764,4.1764
+Omar Marmoush,39.7284,0.3141,0.1159,2.545,4.5655,0.243,0.0,4.9523,4.9523,4.9523,4.9523,1.9523
+Pedro Neto,74.9889,0.1611,0.2136,2.1142,5.2702,0.2074,0.0,11.5034,11.5034,11.5034,11.5034,5.5034
+Marc Guéhi,89.9962,0.1027,0.067,7.5721,11.9556,0.1941,0.0,10.6024,10.6024,10.6024,10.6024,4.6024
+Dominik Szoboszlai,87.0945,0.1507,0.1654,4.0111,9.1403,0.3035,0.0169,11.819,11.819,11.819,11.819,5.819
+Jadon Sancho,42.2505,0.0586,0.1699,1.7187,4.2579,0.0106,0.0,7.0595,7.0595,7.0595,7.0595,4.0595
+Callum Hudson-Odoi,61.8546,0.1233,0.1112,2.0552,5.4534,0.0204,0.0,10.877,10.877,10.877,10.877,4.877
+Lisandro Martínez,67.6503,0.0193,0.049,6.7024,11.4997,0.1211,0.0,10.7857,10.7857,10.7857,10.7857,4.7857
+Santiago Bueno,83.5731,0.0591,0.0346,6.5662,8.9756,0.1334,0.0,9.189,9.189,9.189,9.189,3.189
+Angel Gomes,55.6449,0.1327,0.108,1.9235,4.4371,0.2352,0.0,4.7642,4.7642,4.7642,4.7642,1.7642
+Bruno Guimarães,87.5603,0.1618,0.1555,4.2669,9.2128,0.2123,0.0,10.0484,10.0484,10.0484,10.0484,4.0484
+Daniel Muñoz,85.1446,0.1091,0.145,7.2831,11.5878,0.2468,0.0,11.3449,11.3449,11.3449,11.3449,5.3449
+Edward Nketiah,34.7596,0.3138,0.0749,3.6569,5.753,0.3237,0.0,4.5356,4.5356,4.5356,4.5356,1.5356
+Joseph Willock,34.769,0.0903,0.0641,3.1379,7.0043,0.222,0.0,4.8581,4.8581,4.8581,4.8581,1.8581
+Lukas Nmecha,33.5304,0.4637,0.0749,1.97,4.0155,0.0442,0.0,2.9531,2.9531,2.9531,2.9531,-0.0469
+Jordan Beyer,82.7768,0.0356,0.0137,7.344,11.4945,0.3353,0.0,11.5591,11.5591,11.5591,11.5591,5.5591
+Jaka Bijol,74.6076,0.0466,0.0349,9.2427,12.4264,0.2104,0.0,10.3272,10.3272,10.3272,10.3272,4.3272
+Kaoru Mitoma,71.4493,0.2194,0.1541,2.355,5.5024,0.1542,0.0,9.1618,9.1618,9.1618,9.1618,3.1618
+Matheus Cunha,74.7177,0.28,0.1265,3.5551,7.7408,0.07,0.0,10.7174,10.7174,10.7174,10.7174,4.7174
+Tyrell Malacia,46.5198,0.0187,0.0903,3.3557,6.2804,0.229,0.0,6.2871,6.2871,6.2871,6.2871,3.2871
+Max Kilman,76.0503,0.0361,0.021,7.6773,10.66,0.2022,0.0,9.7598,9.7598,9.7598,9.7598,3.7598
+Matthew O'Riley,48.7145,0.1697,0.2013,3.5878,7.2707,0.1613,0.0,6.4833,6.4833,6.4833,6.4833,3.4833
+Sandro Tonali,70.8519,0.0924,0.098,4.0449,9.4939,0.156,0.0,10.9446,10.9446,10.9446,10.9446,4.9446
+Tijjani Reijnders,61.1749,0.2081,0.1208,2.8274,6.0423,0.1327,0.004,9.5231,9.5231,9.5231,9.5231,3.5231
+Marc Cucurella,77.8184,0.0744,0.1047,6.1965,10.2356,0.2547,0.0293,10.4429,10.4429,10.4429,10.4429,4.4429
+Zian Flemming,56.1101,0.3654,0.0403,3.2368,4.9644,0.1881,0.0,2.914,2.914,2.914,2.914,-0.086
+Vitaliy Mykolenko,89.4196,0.0153,0.0781,6.9707,10.3543,0.162,0.0,10.9489,10.9489,10.9489,10.9489,4.9489
+Calvin Bassey,83.3652,0.061,0.0163,6.4172,11.0544,0.2027,0.0,9.6617,9.6617,9.6617,9.6617,3.6617
+Lyle Foster,56.8255,0.2068,0.0332,2.5299,4.7678,0.2174,0.0039,2.2263,2.2263,2.2263,2.2263,-0.7737
+Lutsharel Geertruida,58.0435,0.0561,0.0629,5.5893,9.447,0.0873,0.0,6.0484,6.0484,6.0484,6.0484,3.0484
+Anton Stach,82.8997,0.089,0.1665,5.5948,10.4824,0.145,0.0,11.8092,11.8092,11.8092,11.8092,5.8092
+Morgan Rogers,88.3658,0.1934,0.1311,2.7539,6.2044,0.2039,0.0,8.6924,8.6924,8.6924,8.6924,2.6924
+Kiernan Dewsbury-Hall,83.7372,0.1551,0.1999,3.2397,7.8093,0.1917,0.0,10.1337,10.1337,10.1337,10.1337,4.1337
+Wilson Isidor,44.9924,0.3056,0.024,2.3576,3.8195,0.1799,0.0,3.4082,3.4082,3.4082,3.4082,0.4082
+Mikkel Damsgaard,62.8194,0.0891,0.2078,4.3437,9.753,0.0407,0.0,11.7283,11.7283,11.7283,11.7283,5.7283
+Emile Smith Rowe,50.3994,0.2185,0.0613,2.9267,5.8764,0.07,0.0,5.5986,5.5986,5.5986,5.5986,2.5986
+Mohammed Kudus,81.3113,0.1378,0.1192,2.7641,7.9315,0.138,0.0012,9.5315,9.5315,9.5315,9.5315,3.5315
+Djed Spence,74.9913,0.0345,0.0545,5.1277,9.8425,0.1589,0.0,10.0475,10.0475,10.0475,10.0475,4.0475
+Anthony Gordon,70.002,0.378,0.1686,2.2215,5.1708,0.1419,0.0325,9.7423,9.7423,9.7423,9.7423,3.7423
+Noah Okafor,50.9246,0.2393,0.0921,2.7505,5.3229,0.1532,0.0,5.7142,5.7142,5.7142,5.7142,2.7142
+Wesley Fofana,69.4005,0.0412,0.0323,8.4518,12.7418,0.4182,0.0,10.8891,10.8891,10.8891,10.8891,4.8891
+Sepp van den Berg,87.9126,0.0936,0.0261,7.9593,11.7922,0.0859,0.0,10.6229,10.6229,10.6229,10.6229,4.6229
+Sebastiaan Bornauw,36.5758,0.0249,0.006,8.4324,10.9426,0.2545,0.0,5.9284,5.9284,5.9284,5.9284,2.9284
+Bryan Mbeumo,85.1668,0.3054,0.1142,2.7242,5.139,0.1031,0.0,9.122,9.122,9.122,9.122,3.122
+Antoine Semenyo,88.9138,0.2902,0.0862,3.0602,7.3072,0.2266,0.0,8.4555,8.4555,8.4555,8.4555,2.4555
+Curtis Jones,50.1405,0.1534,0.1139,3.5072,8.9359,0.098,0.0057,6.5557,6.5557,6.5557,6.5557,3.5557
+Rayan Aït-Nouri,63.3312,0.0623,0.1452,6.6285,11.4224,0.2607,0.0,11.4304,11.4304,11.4304,11.4304,5.4304
+Dejan Kulusevski,74.9837,0.1501,0.1598,2.9581,7.1651,0.1375,0.0,10.2789,10.2789,10.2789,10.2789,4.2789
+Ao Tanaka,41.7624,0.0928,0.0638,3.5551,7.8022,0.1368,0.0,5.5875,5.5875,5.5875,5.5875,2.5875
+Dwight McNeil,54.2643,0.0626,0.1116,2.8592,6.8728,0.0639,0.0,6.8063,6.8063,6.8063,6.8063,3.8063
+Brian Brobbey,56.3609,0.369,0.0675,1.433,3.6041,0.2691,0.0,1.3468,1.3468,1.3468,1.3468,-1.6532
+Crysencio Summerville,72.8909,0.2052,0.1247,3.6236,7.5776,0.1881,0.0,9.8372,9.8372,9.8372,9.8372,3.8372
+Pedro Porro,80.1312,0.0357,0.1355,6.0851,10.5601,0.1834,0.0001,13.0023,13.0023,13.0023,13.0023,7.0023
+Jérémy Doku,55.4217,0.1166,0.2592,2.4255,5.8968,0.0267,0.0,9.6308,9.6308,9.6308,9.6308,6.6308
+Jurriën Timber,81.7478,0.1352,0.0566,4.9202,8.453,0.163,0.0,8.4838,8.4838,8.4838,8.4838,2.4838
+James Garner,87.9932,0.0579,0.12,6.7268,11.3205,0.238,0.0,12.3629,12.3629,12.3629,12.3629,6.3629
+Maxence Lacroix,88.8248,0.0759,0.0295,10.052,13.225,0.1215,0.0302,10.8061,10.8061,10.8061,10.8061,4.8061
+Chris Richards,88.4833,0.0524,0.0389,9.3739,11.5966,0.1255,0.0,10.9864,10.9864,10.9864,10.9864,4.9864
+Joshua Zirkzee,30.2633,0.3208,0.0407,2.6714,5.0198,0.0544,0.0,3.8421,3.8421,3.8421,3.8421,0.8421
+Cheick Oumar Doucouré,40.6868,0.0677,0.0246,7.2194,12.6153,0.1742,0.0,8.874,8.874,8.874,8.874,5.874
+Brenden Aaronson,67.7086,0.1865,0.1258,2.9428,7.1965,0.0547,0.0,7.5291,7.5291,7.5291,7.5291,1.5291
+Jean-Clair Todibo,82.7585,0.0152,0.0152,7.6511,12.3198,0.1855,0.0392,10.5206,10.5206,10.5206,10.5206,4.5206
+Benoît Badiashile,59.8826,0.0036,0.0179,7.7847,11.3785,0.1728,0.0,6.8641,6.8641,6.8641,6.8641,3.8641
+William Saliba,83.5068,0.044,0.0369,6.0477,10.4926,0.0541,0.0066,9.8505,9.8505,9.8505,9.8505,3.8505
+Matheus Nunes,81.4042,0.0339,0.0811,5.2496,9.9578,0.1643,0.0,10.4925,10.4925,10.4925,10.4925,4.4925
+Sven Botman,65.1075,0.1052,0.0322,8.018,12.3429,0.1102,0.0,11.3157,11.3157,11.3157,11.3157,5.3157
+Ryan Gravenberch,86.7602,0.0593,0.0809,5.2197,9.8207,0.1517,0.0063,10.193,10.193,10.193,10.193,4.193
+Georginio Rutter,63.9676,0.1576,0.119,3.6218,6.3912,0.1092,0.0,6.5947,6.5947,6.5947,6.5947,0.5947
+James Hill,64.1043,0.0461,0.0525,10.1973,13.1687,0.109,0.0,10.9715,10.9715,10.9715,10.9715,4.9715
+Nathan Collins,84.472,0.059,0.0372,8.0217,12.1997,0.1643,0.0012,10.5104,10.5104,10.5104,10.5104,4.5104
+Bukayo Saka,73.4966,0.3093,0.2918,3.4473,7.7153,0.0813,0.0,12.9207,12.9207,12.9207,12.9207,6.9207
+Harvey Elliott,22.5166,0.2129,0.1671,2.8267,6.2156,0.2193,0.0,8.4308,8.4308,8.4308,8.4308,5.4308
+Fábio Carvalho,28.8812,0.1797,0.0415,1.6262,3.4287,0.0982,0.0,5.1529,5.1529,5.1529,5.1529,2.1529
+Mike Trésor,36.8008,0.0642,0.1648,1.184,3.616,0.0017,0.0017,6.4262,6.4262,6.4262,6.4262,3.4262
+Iliman Ndiaye,84.6356,0.1811,0.1314,3.1757,8.5839,0.0686,0.0076,9.3019,9.3019,9.3019,9.3019,3.3019
+Jeremie Frimpong,52.7721,0.0894,0.1165,2.6995,6.4162,0.1354,0.0,6.8415,6.8415,6.8415,6.8415,3.8415
+Ian Maatsen,50.6532,0.0752,0.1464,6.7681,12.0113,0.1654,0.0007,8.7191,8.7191,8.7191,8.7191,5.7191
+Conor Gallagher,45.5652,0.1023,0.0606,3.5725,7.6757,0.1487,0.0,4.851,4.851,4.851,4.851,1.851
+Mats Wieffer,70.4765,0.085,0.112,8.5908,13.8246,0.3667,0.0,11.2894,11.2894,11.2894,11.2894,5.2894
+Daniel Ballard,75.1937,0.1039,0.023,9.6698,12.9468,0.1763,0.0,10.4749,10.4749,10.4749,10.4749,4.4749
+Jan Paul van Hecke,88.743,0.0665,0.0399,7.5791,10.9377,0.241,0.0,10.534,10.534,10.534,10.534,4.534
+Mykhaylo Mudryk,49.9593,0.1785,0.1263,1.5776,3.9101,0.1768,0.0,6.1416,6.1416,6.1416,6.1416,3.1416
+Bafodé Diakité,73.2366,0.0267,0.018,6.9524,10.7379,0.1204,0.0,9.1616,9.1616,9.1616,9.1616,3.1616
+Aaron Hickey,39.0821,0.0472,0.0138,4.007,8.0058,0.2139,0.0,4.8348,4.8348,4.8348,4.8348,1.8348
+Keane Lewis-Potter,50.8241,0.1252,0.0911,3.1746,6.7151,0.0641,0.0,6.6342,6.6342,6.6342,6.6342,3.6342
+Toti Gomes,75.7854,0.0232,0.0178,7.4731,11.1206,0.1909,0.0585,11.2192,11.2192,11.2192,11.2192,5.2192
+João Pedro,74.5961,0.4491,0.0975,2.7339,5.3033,0.1342,0.0017,7.8275,7.8275,7.8275,7.8275,1.8275
+Gabriel Martinelli,38.517,0.3204,0.1163,2.3713,6.0777,0.1571,0.0,5.797,5.797,5.797,5.797,2.797
+Jacob Ramsey,51.8772,0.0787,0.072,2.809,7.1378,0.2834,0.0,5.7475,5.7475,5.7475,5.7475,2.7475
+Dan Ndoye,53.7756,0.1437,0.1097,3.052,6.5955,0.1126,0.0,4.8186,4.8186,4.8186,4.8186,1.8186
+Jarrad Branthwaite,65.6957,0.0545,0.0149,7.4916,10.0959,0.1094,0.0,10.6128,10.6128,10.6128,10.6128,4.6128
+Martín Zubimendi,83.7754,0.0914,0.0671,5.4809,9.3376,0.1594,0.0,8.825,8.825,8.825,8.825,2.825
+Manuel Ugarte,40.8001,0.0458,0.0183,7.7418,13.5736,0.2448,0.0,5.6427,5.6427,5.6427,5.6427,2.6427
+Yéremi Pino,64.1448,0.168,0.1828,3.3484,6.6032,0.2267,0.0,7.8596,7.8596,7.8596,7.8596,1.8596
+David Møller Wolfe,51.4133,0.0231,0.0918,3.5772,6.1115,0.1064,0.0075,5.8076,5.8076,5.8076,5.8076,2.8076
+Enzo Le Fée,77.9089,0.0922,0.1025,4.3836,8.4172,0.1585,0.0,9.136,9.136,9.136,9.136,3.136
+Anthony Elanga,47.8001,0.1197,0.1091,1.7247,4.1398,0.0103,0.0,5.9113,5.9113,5.9113,5.9113,2.9113
+Destiny Udogie,62.3729,0.0225,0.0597,4.1026,8.8399,0.2853,0.0,8.9714,8.9714,8.9714,8.9714,2.9714
+Morato,59.2415,0.0627,0.0072,7.761,11.9325,0.3581,0.0,6.7099,6.7099,6.7099,6.7099,3.7099
+Zeki Amdouni,36.9928,0.2697,0.1307,2.0035,4.7107,0.0719,0.0,5.4058,5.4058,5.4058,5.4058,2.4058
+Trai Hume,83.8605,0.0516,0.0786,6.0814,9.6169,0.2459,0.0035,9.4073,9.4073,9.4073,9.4073,3.4073
+Amad,76.2846,0.1979,0.1464,3.8464,8.2369,0.127,0.0,11.9631,11.9631,11.9631,11.9631,5.9631
+Josko Gvardiol,82.4289,0.132,0.0505,5.8602,9.3448,0.1236,0.0,9.4467,9.4467,9.4467,9.4467,3.4467
+Tolu Arokodare,47.0023,0.3963,0.0367,3.0882,4.6942,0.0699,0.0,2.9966,2.9966,2.9966,2.9966,-0.0034
+Benjamin Sesko,54.0307,0.3938,0.0282,1.8954,3.5596,0.1098,0.0,3.9752,3.9752,3.9752,3.9752,0.9752
+Brennan Johnson,48.2584,0.1793,0.0594,2.7178,4.8402,0.2576,0.0,5.1978,5.1978,5.1978,5.1978,2.1978
+Armando Broja,39.6652,0.1686,0.0185,2.2725,4.879,0.0608,0.0,2.2664,2.2664,2.2664,2.2664,-0.7336
+Neco Williams,85.5942,0.0473,0.0941,7.1224,11.4786,0.1919,0.0232,11.8488,11.8488,11.8488,11.8488,5.8488
+Beto,40.4443,0.5053,0.0204,3.5613,5.5271,0.1434,0.0,2.2369,2.2369,2.2369,2.2369,-0.7631
+Dilane Bakwa,40.8753,0.0669,0.1378,1.3206,4.0673,0.1119,0.0022,6.4094,6.4094,6.4094,6.4094,3.4094
+Amine Adli,38.1867,0.237,0.143,3.9854,7.8686,0.2161,0.0064,4.9567,4.9567,4.9567,4.9567,1.9567
+Ilia Gruev,63.2283,0.0354,0.0668,3.6066,6.7117,0.1882,0.0,8.1832,8.1832,8.1832,8.1832,2.1832
+Rhys Williams,59.0793,0.0311,0.0042,4.4377,7.1301,0.1056,0.0,5.3508,5.3508,5.3508,5.3508,2.3508
+Igor Jesus,63.9319,0.2378,0.06,2.9492,5.014,0.0026,0.0,6.4646,6.4646,6.4646,6.4646,0.4646
+Kevin Schade,76.7491,0.3576,0.0728,3.117,6.2569,0.1911,0.0277,7.4974,7.4974,7.4974,7.4974,1.4974
+Noni Madueke,47.4266,0.1796,0.187,3.1812,5.7814,0.04,0.0,8.2122,8.2122,8.2122,8.2122,5.2122
+Evann Guessand,50.761,0.1614,0.0986,3.1724,6.6787,0.0137,0.0,3.2438,3.2438,3.2438,3.2438,0.2438
+Elliot Anderson,87.4783,0.0747,0.1151,5.9657,13.6638,0.2265,0.0,12.1632,12.1632,12.1632,12.1632,6.1632
+Cole Palmer,73.2632,0.419,0.1574,3.1644,6.8453,0.1888,0.0,11.2779,11.2779,11.2779,11.2779,5.2779
+Levi Colwill,88.4326,0.0535,0.0325,5.7177,9.4928,0.1771,0.0,10.0217,10.0217,10.0217,10.0217,4.0217
+Micky van de Ven,85.1519,0.0778,0.0244,5.8832,10.0799,0.2736,0.0252,9.7194,9.7194,9.7194,9.7194,3.7194
+Evanilson,75.6931,0.3737,0.0697,1.8184,4.4082,0.054,0.0119,6.3751,6.3751,6.3751,6.3751,0.3751
+Jake O'Brien,84.1167,0.0411,0.0245,6.3849,9.7345,0.1108,0.0264,9.8303,9.8303,9.8303,9.8303,3.8303
+Rayan Cherki,54.3913,0.1635,0.4037,2.6652,6.9577,0.0351,0.0,9.4148,9.4148,9.4148,9.4148,6.4148
+Riccardo Calafiori,64.265,0.1363,0.0427,5.4635,8.7902,0.2974,0.0,9.6881,9.6881,9.6881,9.6881,3.6881
+Nick Woltemade,62.3292,0.3229,0.0801,2.7048,5.1076,0.0607,0.0,6.0716,6.0716,6.0716,6.0716,0.0716
+Pape Sarr,59.1841,0.0941,0.0608,5.6164,10.1001,0.1794,0.0,6.1899,6.1899,6.1899,6.1899,3.1899
+Wilfried Gnonto,27.5683,0.1078,0.0812,4.0544,7.2597,0.5541,0.0,4.5717,4.5717,4.5717,4.5717,1.5717
+James McAtee,27.5175,0.3906,0.0589,1.9007,4.4039,0.05,0.0,4.8698,4.8698,4.8698,4.8698,1.8698
+Valentino Livramento,77.4971,0.0117,0.0683,4.6642,9.7494,0.0636,0.0,10.7559,10.7559,10.7559,10.7559,4.7559
+Nathan Patterson,36.5152,0.0191,0.0666,3.9913,6.9515,0.2266,0.0,8.4676,8.4676,8.4676,8.4676,5.4676
+Conor Bradley,59.1709,0.0417,0.1062,5.5976,9.7784,0.3706,0.0,6.4553,6.4553,6.4553,6.4553,3.4553
+Jamie Gittens,33.9062,0.1518,0.168,2.677,5.0836,0.0396,0.0,6.7743,6.7743,6.7743,6.7743,3.7743
+Hannibal Mejbri,44.2434,0.0529,0.0929,3.044,6.8682,0.4251,0.0055,4.1983,4.1983,4.1983,4.1983,1.1983
+Oscar Bobb,44.2702,0.0958,0.1222,2.2547,4.8983,0.0798,0.0,5.4626,5.4626,5.4626,5.4626,2.4626
+Liam Delap,40.4728,0.3733,0.03,1.9564,3.9814,0.2554,0.0,3.1751,3.1751,3.1751,3.1751,0.1751
+Hugo Bueno,68.4422,0.0253,0.1305,5.3578,9.4965,0.0641,0.0,11.8044,11.8044,11.8044,11.8044,5.8044
+Dennis Cirkin,47.6861,0.0236,0.03,7.0159,9.5407,0.3101,0.0,5.4027,5.4027,5.4027,5.4027,2.4027
+Tyrick Mitchell,88.2977,0.0373,0.0663,6.0499,10.7413,0.1388,0.0002,10.611,10.611,10.611,10.611,4.611
+Romaine Mundle,33.2089,0.1008,0.071,1.9692,4.8319,0.1818,0.0,4.7529,4.7529,4.7529,4.7529,1.7529
+Rodrigo Muniz,40.0262,0.3739,0.0149,4.0327,5.5515,0.1365,0.0,2.7251,2.7251,2.7251,2.7251,-0.2749
+Charly Alcaraz,42.8533,0.1419,0.0488,2.9617,6.9197,0.2908,0.0002,6.2849,6.2849,6.2849,6.2849,3.2849
+Maxim De Cuyper,52.6835,0.1024,0.1978,4.4165,8.7511,0.1667,0.0,8.2956,8.2956,8.2956,8.2956,5.2956
+Malick Thiaw,83.1742,0.1063,0.0265,7.6616,11.3401,0.1308,0.0041,9.6095,9.6095,9.6095,9.6095,3.6095
+Piero Hincapié,73.8871,0.0494,0.1001,7.0821,10.4495,0.1436,0.0005,9.4022,9.4022,9.4022,9.4022,3.4022
+Moisés Caicedo,84.5964,0.0438,0.0827,6.294,12.2565,0.3618,0.0279,9.8801,9.8801,9.8801,9.8801,3.8801
+Enzo Fernández,85.5435,0.2904,0.1775,3.297,7.2089,0.2562,0.0,9.6035,9.6035,9.6035,9.6035,3.6035
+Florian Wirtz,74.8042,0.2429,0.2216,2.5447,6.569,0.0429,0.0,10.4041,10.4041,10.4041,10.4041,4.4041
+Luca Netz,55.6359,0.0114,0.0975,5.3227,8.2574,0.1027,0.0,6.3843,6.3843,6.3843,6.3843,3.3843
+Yehor Yarmoliuk,66.0112,0.0527,0.0524,4.6962,10.9059,0.2907,0.0,9.5066,9.5066,9.5066,9.5066,3.5066
+Yasin Ayari,69.9335,0.1259,0.0633,4.6479,9.952,0.0975,0.0,9.9672,9.9672,9.9672,9.9672,3.9672
+Jaidon Anthony,72.6748,0.1919,0.119,3.4996,8.085,0.1314,0.0,10.0903,10.0903,10.0903,10.0903,4.0903
+Xavi Simons,67.7422,0.1262,0.1733,2.9025,6.9162,0.1599,0.0051,9.8097,9.8097,9.8097,9.8097,3.8097
+André,76.1536,0.0164,0.0294,5.5794,12.4388,0.3411,0.0008,9.6029,9.6029,9.6029,9.6029,3.6029
+Savinho,36.8613,0.2163,0.2094,2.3971,6.3809,0.1433,0.0,6.2472,6.2472,6.2472,6.2472,3.2472
+João Gomes,79.758,0.0537,0.0687,6.048,12.2979,0.3581,0.0,9.5715,9.5715,9.5715,9.5715,3.5715
+Adrien Truffert,88.0282,0.0184,0.0719,7.2286,11.8795,0.1773,0.0,10.4511,10.4511,10.4511,10.4511,4.4511
+Amadou Onana,70.0839,0.0818,0.0401,6.703,10.5225,0.1547,0.0,9.3309,9.3309,9.3309,9.3309,3.3309
+Chadi Riad,56.3634,0.014,0.0115,4.6692,7.8654,0.3977,0.0,4.9832,4.9832,4.9832,4.9832,1.9832
+Lamare Bogarde,36.7301,0.0009,0.0154,3.6559,6.2708,0.3471,0.0,3.9567,3.9567,3.9567,3.9567,0.9567
+Roméo Lavia,53.5626,0.0198,0.033,4.7947,9.3437,0.3879,0.0,7.4837,7.4837,7.4837,7.4837,4.4837
+Rodrigo Gomes,39.0568,0.1139,0.0311,2.7758,4.6043,0.0221,0.0009,5.4555,5.4555,5.4555,5.4555,2.4555
+Milos Kerkez,74.6702,0.0564,0.0421,5.2971,9.0952,0.1622,0.0037,10.4707,10.4707,10.4707,10.4707,4.4707
+Hugo Ekitiké,66.4009,0.4976,0.1483,2.7203,4.9566,0.0313,0.0,7.5181,7.5181,7.5181,7.5181,1.5181
+Malo Gusto,61.7349,0.0687,0.0942,4.5824,9.2656,0.1836,0.0039,9.9092,9.9092,9.9092,9.9092,3.9092
+Lewis Hall,74.0676,0.0363,0.1285,6.3973,11.6696,0.1857,0.0,12.6354,12.6354,12.6354,12.6354,6.6354
+Lesley Ugochukwu,65.7971,0.0746,0.0162,4.0754,7.6819,0.1931,0.0014,7.7189,7.7189,7.7189,7.7189,1.7189
+Yerson Mosquera,79.1589,0.0829,0.021,8.3565,12.7682,0.4628,0.0,9.7487,9.7487,9.7487,9.7487,3.7487
+Tim Iroegbunam,45.9773,0.0283,0.0395,5.6372,10.5542,0.5435,0.0,4.1552,4.1552,4.1552,4.1552,1.1552
+Radu Dragusin,65.9592,0.038,0.0066,8.589,10.9029,0.0224,0.0,9.2994,9.2994,9.2994,9.2994,3.2994
+Alejandro Garnacho,56.8659,0.2823,0.2016,2.3743,5.5952,0.1103,0.0,5.9239,5.9239,5.9239,5.9239,2.9239
+Freddie Potts,60.5335,0.0201,0.0578,4.5424,7.2826,0.0968,0.0,8.4227,8.4227,8.4227,8.4227,2.4227
+Merlin Röhl,32.9049,0.0749,0.0465,2.738,5.5585,0.0412,0.0049,2.5037,2.5037,2.5037,2.5037,-0.4963
+Jackson Tchatchoua,64.2148,0.0204,0.0814,4.1717,6.9474,0.1357,0.0,9.5712,9.5712,9.5712,9.5712,3.5712
+Lorenzo Lucca,33.1764,0.2448,0.022,1.5783,3.3428,0.2117,0.0,1.0382,1.0382,1.0382,1.0382,-1.9618
+Omari Hutchinson,57.2744,0.1063,0.1601,2.3051,6.2931,0.0417,0.0,7.6288,7.6288,7.6288,7.6288,4.6288
+Pablo,64.4283,0.2275,0.0431,3.851,6.6621,0.1542,0.0,5.3899,5.3899,5.3899,5.3899,-0.6101
+Alex Scott,69.975,0.106,0.0705,6.2624,12.1883,0.1737,0.0,11.4208,11.4208,11.4208,11.4208,5.4208
+Bashir Humphreys,81.1397,0.0233,0.0369,6.6504,10.5548,0.2584,0.0,9.299,9.299,9.299,9.299,3.299
+Abdoullah Ba,49.0535,0.0344,0.018,1.7816,3.553,0.156,0.0,3.9978,3.9978,3.9978,3.9978,0.9978
+Dário Essugo,67.6699,0.0205,0.0237,5.8284,10.6501,0.414,0.0,7.7005,7.7005,7.7005,7.7005,1.7005
+Dango Ouattara,67.9332,0.2433,0.1385,4.9938,8.7618,0.1329,0.0,9.7569,9.7569,9.7569,9.7569,3.7569
+Maxime Estève,86.1932,0.0237,0.0199,8.446,12.0462,0.0371,0.0,10.2633,10.2633,10.2633,10.2633,4.2633
+Jack Hinshelwood,60.5217,0.1213,0.0791,2.7297,5.4951,0.0543,0.0,7.2746,7.2746,7.2746,7.2746,1.2746
+William Osula,19.2368,0.1565,0.0087,1.3492,3.9219,0.0869,0.0,3.197,3.197,3.197,3.197,0.197
+Antoni Milambo,59.7901,0.061,0.056,2.0112,4.3345,0.0703,0.0,4.0933,4.0933,4.0933,4.0933,1.0933
+Nico González,67.2308,0.0669,0.0655,5.6697,10.3898,0.3059,0.012,8.2844,8.2844,8.2844,8.2844,2.2844
+Loum Tchaouna,35.8091,0.0873,0.0429,2.3049,3.8955,0.0852,0.0,3.8896,3.8896,3.8896,3.8896,0.8896
+Habib Diarra,78.6907,0.1435,0.0668,2.43,5.0999,0.2505,0.0,7.3148,7.3148,7.3148,7.3148,1.3148
+Nilson Angulo,74.7504,0.1447,0.1535,4.4396,8.2221,0.0664,0.0277,9.508,9.508,9.508,9.508,3.508
+Mathys Tel,39.7785,0.1452,0.1017,2.2308,5.6376,0.0489,0.0,6.9962,6.9962,6.9962,6.9962,3.9962
+Rico Lewis,35.9394,0.035,0.184,4.0896,6.5026,0.2169,0.0,5.0106,5.0106,5.0106,5.0106,2.0106
+Lucas Pires,74.6837,0.0295,0.0963,5.6565,10.7784,0.0756,0.0944,10.6803,10.6803,10.6803,10.6803,4.6803
+Kobbie Mainoo,52.6321,0.0221,0.0694,4.3792,9.0528,0.0931,0.0,6.6021,6.6021,6.6021,6.6021,3.6021
+Tyler Dibling,28.1028,0.0668,0.0286,3.3806,6.5985,0.3403,0.0,4.9808,4.9808,4.9808,4.9808,1.9808
+Adam Wharton,78.6639,0.0443,0.222,5.1207,10.6841,0.2299,0.0,10.5291,10.5291,10.5291,10.5291,4.5291
+Lewis Miley,54.6858,0.115,0.1326,5.2793,9.7618,0.0056,0.0,8.6122,8.6122,8.6122,8.6122,5.6122
+Cristhian Mosquera,53.2567,0.0042,0.0451,6.8703,10.331,0.2289,0.0,5.8415,5.8415,5.8415,5.8415,2.8415
+Nico O'Reilly,73.911,0.2047,0.0445,2.454,4.5921,0.0081,0.0,7.8341,7.8341,7.8341,7.8341,1.8341
+Igor Thiago,84.1711,0.4805,0.0671,2.9572,5.361,0.1937,0.0,6.1205,6.1205,6.1205,6.1205,0.1205
+Stefan Bajcetic,59.3475,0.0519,0.0448,4.5461,9.4227,0.3219,0.0,6.1406,6.1406,6.1406,6.1406,3.1406
+Oliver Scarles,53.3652,0.0191,0.0324,7.4714,11.4437,0.1237,0.0,7.1103,7.1103,7.1103,7.1103,4.1103
+Kevin,34.1777,0.0634,0.0646,2.1876,4.5723,0.0503,0.0,6.6387,6.6387,6.6387,6.6387,3.6387
+Archie Gray,65.1382,0.055,0.0493,3.8137,6.8077,0.2325,0.0,8.4235,8.4235,8.4235,8.4235,2.4235
+Ben Gannon Doak,64.7464,0.1483,0.1535,1.5251,3.7866,0.2385,0.0,8.051,8.051,8.051,8.051,2.051
+Chris Rigg,46.8007,0.0961,0.0755,2.7371,5.5118,0.0775,0.0,5.062,5.062,5.062,5.062,2.062
+Facundo Buonanotte,49.0491,0.1925,0.1038,4.0942,8.1996,0.4277,0.0,7.7681,7.7681,7.7681,7.7681,4.7681
+Justin Devenny,31.6051,0.2037,0.0279,2.75,5.2427,0.0414,0.0,4.0726,4.0726,4.0726,4.0726,1.0726
+Wilson Odobert,42.7042,0.1212,0.0855,1.8053,5.4487,0.0136,0.0,6.5921,6.5921,6.5921,6.5921,3.5921
+Matai Akinmboni,47.6087,0.0,0.0139,5.0187,6.9676,0.4176,0.0,4.2692,4.2692,4.2692,4.2692,1.2692
+Carlos Baleba,55.043,0.0557,0.0241,5.4221,10.838,0.3316,0.007,5.4047,5.4047,5.4047,5.4047,2.4047
+Diego Gómez,64.5466,0.2046,0.1314,6.0073,10.6088,0.3024,0.0,8.7393,8.7393,8.7393,8.7393,2.7393
+Sverre Halseth Nypan,36.087,0.1136,0.1175,2.903,5.9343,0.1227,0.0,5.0612,5.0612,5.0612,5.0612,2.0612
+Mateus Fernandes,80.7045,0.0542,0.0914,4.7553,9.6293,0.2344,0.0,9.646,9.646,9.646,9.646,3.646
+Leny Yoro,57.3727,0.0534,0.02,6.8264,10.3785,0.1369,0.0,5.9604,5.9604,5.9604,5.9604,2.9604
+Noah Sadiki,87.8127,0.0309,0.0578,4.418,8.1771,0.2588,0.0,8.4581,8.4581,8.4581,8.4581,2.4581
+Abdukodir Khusanov,61.6895,0.0384,0.0133,7.2718,11.525,0.2245,0.0136,9.7882,9.7882,9.7882,9.7882,3.7882
+Chemsdine Talbi,56.0204,0.188,0.0912,2.0419,5.1501,0.0167,0.0,6.3764,6.3764,6.3764,6.3764,3.3764
+Andrey Santos,49.566,0.1425,0.0648,5.4552,9.1263,0.3535,0.0,5.1698,5.1698,5.1698,5.1698,2.1698
+Soungoutou Magassa,48.5963,0.0379,0.033,5.8709,10.5145,0.2782,0.0221,5.0851,5.0851,5.0851,5.0851,2.0851
+Lucas Bergvall,47.0821,0.0997,0.0397,3.5438,6.8469,0.1322,0.0,5.5429,5.5429,5.5429,5.5429,2.5429
+Eliezer Mayenda,42.5331,0.24,0.0339,1.498,3.2026,0.0254,0.0,3.809,3.809,3.809,3.809,0.809
+Jair Cunha,78.2609,0.0296,0.0234,5.3585,7.6989,0.0,0.0,8.9667,8.9667,8.9667,8.9667,2.9667
+Quilindschy Hartman,83.8798,0.0203,0.1325,6.1767,10.3337,0.0543,0.0,10.8879,10.8879,10.8879,10.8879,4.8879
+Thierno Barry,54.7965,0.3265,0.0305,1.624,2.9763,0.1461,0.0,1.4231,1.4231,1.4231,1.4231,-1.5769
+Josh Acheampong,41.4493,0.0647,0.0103,3.3667,5.5666,0.1078,0.0,5.8245,5.8245,5.8245,5.8245,2.8245
+Yankuba Minteh,66.6667,0.1704,0.1841,3.6228,7.3012,0.1046,0.0,11.1513,11.1513,11.1513,11.1513,5.1513
+Myles Lewis-Skelly,27.1483,0.0028,0.0135,3.0567,5.8486,0.6636,0.0255,4.6424,4.6424,4.6424,4.6424,1.6424
+Jorrel Hato,45.6886,0.0241,0.0773,5.4521,8.9835,0.5255,0.0,5.5977,5.5977,5.5977,5.5977,2.5977
+Max Alleyne,67.6812,0.0181,0.0111,4.8975,6.9992,0.0876,0.0,8.5682,8.5682,8.5682,8.5682,2.5682
+Joshua King,39.687,0.1127,0.0257,1.7447,4.1208,0.1371,0.0,4.5216,4.5216,4.5216,4.5216,1.5216
+Stefanos Tzimas,65.0662,0.3403,0.0715,0.7995,2.4534,0.1369,0.0214,6.1438,6.1438,6.1438,6.1438,0.1438
+Enock Agyei,36.087,0.0114,0.008,0.3475,1.5197,0.0,0.0,3.0862,3.0862,3.0862,3.0862,0.0862
+Mamadou Sarr,82.8463,0.0204,0.0115,5.8743,10.2864,0.1702,0.0,8.8205,8.8205,8.8205,8.8205,2.8205
+Michael Kayode,81.3504,0.0231,0.0846,5.7577,10.3235,0.2103,0.0,11.1479,11.1479,11.1479,11.1479,5.1479
+Andrés García,39.3789,0.0083,0.0595,3.5976,6.7248,0.0,0.0,7.5357,7.5357,7.5357,7.5357,4.5357
+Malick Diouf,79.3391,0.0275,0.0605,5.3465,8.3409,0.1667,0.0,9.4961,9.4961,9.4961,9.4961,3.4961
+Murillo,87.2138,0.032,0.0234,8.7374,13.1184,0.1774,0.0,11.331,11.331,11.331,11.331,5.331
+Junior Kroupi,47.2251,0.2823,0.0467,2.9825,5.9706,0.1474,0.0,5.6279,5.6279,5.6279,5.6279,2.6279
+James Wilson,46.3406,0.1614,0.0851,1.3494,2.2801,0.0436,0.0436,3.3639,3.3639,3.3639,3.3639,0.3639
+Rayan,78.9209,0.1978,0.1078,3.3234,6.1355,0.1375,0.0,10.1546,10.1546,10.1546,10.1546,4.1546
+Patrick Dorgu,63.2964,0.0914,0.0816,4.7119,8.3529,0.22,0.0076,8.876,8.876,8.876,8.876,2.876
+Álex Jiménez,69.9209,0.0469,0.0511,5.0965,9.0096,0.3132,0.0,9.533,9.533,9.533,9.533,3.533
+Harrison Armstrong,58.9689,0.0364,0.0312,3.5776,6.5468,0.1578,0.0,4.9258,4.9258,4.9258,4.9258,1.9258
+Ayden Heaven,40.559,0.0318,0.0159,6.3629,9.3591,0.187,0.0,5.4968,5.4968,5.4968,5.4968,2.4968
+Charalampos Kostoulas,15.9783,0.2464,0.0102,2.2866,4.065,0.0,0.0,3.2117,3.2117,3.2117,3.2117,0.2117
+Adam Aznou,51.5362,0.0198,0.0075,3.8672,7.0893,0.0784,0.0,5.0892,5.0892,5.0892,5.0892,2.0892
+Christantus Uche,31.8291,0.2331,0.0864,1.5818,3.6167,0.1936,0.0053,1.905,1.905,1.905,1.905,-1.095
+Estêvão,43.0435,0.2243,0.1664,2.6617,5.8927,0.2848,0.0,7.1788,7.1788,7.1788,7.1788,4.1788
+Giovanni Leoni,61.5345,0.0116,0.001,4.2887,5.8787,0.1515,0.0505,7.9432,7.9432,7.9432,7.9432,1.9432
+Mateus Mané,55.7391,0.0556,0.0656,1.8953,4.0527,0.0,0.0,4.8833,4.8833,4.8833,4.8833,1.8833
+Nicolò Savona,71.7671,0.086,0.0314,3.6126,5.7055,0.1586,0.0,8.5127,8.5127,8.5127,8.5127,2.5127
+Milan Aleksic,31.3043,0.0303,0.0127,0.8607,1.8096,0.0505,0.0,3.7423,3.7423,3.7423,3.7423,0.7423
+Souza,59.4203,0.0392,0.0459,3.9552,6.2499,0.1446,0.0,5.669,5.669,5.669,5.669,2.669
+Alysson Edward,48.4993,0.1025,0.0419,2.6552,4.799,0.2144,0.0,4.0441,4.0441,4.0441,4.0441,1.0441
+Jaydee Canvot,59.2536,0.06,0.019,7.4195,10.4194,0.2124,0.0,5.5089,5.5089,5.5089,5.5089,2.5089
+Veljko Milosavljevic,45.5901,0.025,0.0317,5.6644,7.8302,0.0,0.0,5.8044,5.8044,5.8044,5.8044,2.8044
diff --git a/statistical_weighted_baselines_gk.csv b/statistical_weighted_baselines_gk.csv
new file mode 100644
index 0000000000000000000000000000000000000000..8966ef69bfaa1521ce7f5c3718e44b7f5f3c431a
--- /dev/null
+++ b/statistical_weighted_baselines_gk.csv
@@ -0,0 +1,54 @@
+player_name,baseline_xMins,baseline_xSaves_p90,baseline_xA_p90,baseline_yc_p90,baseline_rc_p90,baseline_pksave_p90,baseline_gk_bps_p90,baseline_bps_floor_p90
+Lukasz Fabianski,81.5161,3.7797,0.0,0.1072,0.0,0.0022,8.7647,2.7647
+John Ruddy,89.8423,2.9198,0.0024,0.0361,0.0,0.0105,9.2519,3.2519
+Martin Dúbravka,90.0,3.3718,0.0009,0.0252,0.0,0.0034,9.2175,3.2175
+Fraser Forster,89.5807,3.3368,0.0,0.0166,0.0,0.0166,8.1673,2.1673
+Jason Steele,90.0,2.2652,0.0022,0.0149,0.0,0.0149,8.7011,2.7011
+Karl Darlow,90.0,2.1308,0.0018,0.0895,0.0,0.0,9.3762,3.3762
+Simon Moore,90.0,1.9512,0.0,0.1694,0.0,0.0,9.1809,3.1809
+Matz Sels,85.5435,2.9536,0.0036,0.0911,0.0,0.0,8.6653,2.6653
+Daniel Bentley,78.1739,1.9216,0.0,0.0,0.0,0.0,7.9909,1.9909
+Marco Bizot,82.1739,2.8014,0.0002,0.3387,0.0023,0.0106,8.7602,2.7602
+Benjamin Lecomte,89.2174,3.5556,0.0016,0.0103,0.0026,0.0357,8.7255,2.7255
+Alphonse Areola,89.5936,3.7461,0.0,0.0025,0.0,0.005,8.7468,2.7468
+Bernd Leno,90.0,2.6904,0.0002,0.1029,0.0,0.0066,9.141,3.141
+Václav Hladky,78.242,1.6942,0.0022,0.0589,0.0,0.0,8.2933,2.2933
+Emiliano Martínez,88.0748,2.8527,0.0019,0.0964,0.0063,0.0326,9.2093,3.2093
+Stefan Ortega,89.2876,2.737,0.0023,0.0183,0.0,0.0,7.4951,1.4951
+Sam Johnstone,90.0,3.16,0.0011,0.0064,0.0,0.0,9.301,3.301
+Nick Pope,89.4862,3.0524,0.0,0.054,0.0007,0.0093,9.6859,3.6859
+Jordan Pickford,90.0,2.9164,0.0059,0.0987,0.0,0.0343,9.3661,3.3661
+Kepa Arrizabalaga,89.413,2.8622,0.0007,0.085,0.0,0.0,8.8415,2.8415
+Alisson Becker,89.9488,2.4353,0.0024,0.0304,0.0,0.0007,9.411,3.411
+José Sá,89.9955,2.6995,0.0011,0.0903,0.0,0.0168,9.7735,3.7735
+Walter Benítez,88.8293,1.8642,0.0029,0.0118,0.0,0.0,8.4906,2.4906
+Guglielmo Vicario,90.0,2.7397,0.0009,0.0375,0.0,0.0008,9.1905,3.1905
+Remi Matthews,78.2609,1.7934,0.0008,0.0399,0.0,0.0,8.2351,2.2351
+David Raya,90.0,1.8047,0.0019,0.0448,0.0,0.0023,9.3015,3.3015
+Freddie Woodman,89.9765,2.4971,0.0004,0.13,0.0183,0.0183,9.4773,3.4773
+Gianluigi Donnarumma,89.6304,2.4607,0.0,0.181,0.0031,0.0288,8.8206,2.8206
+Lucas Perri,90.0,2.7199,0.0002,0.0999,0.0,0.0,8.5959,2.5959
+Dean Henderson,90.0,2.9357,0.0023,0.0987,0.0,0.032,9.2588,3.2588
+Mark Travers,90.0,3.6092,0.0006,0.0308,0.0,0.0,8.8755,2.8755
+Aaron Ramsdale,83.3043,2.6602,0.0004,0.0896,0.0,0.0149,9.1422,3.1422
+John Victor,87.913,1.7653,0.0,0.1748,0.0,0.0022,8.3193,2.3193
+Angus Gunn,88.6739,2.4823,0.0002,0.0275,0.0,0.0025,9.1523,3.1523
+Caoimhín Kelleher,90.0,2.6642,0.0048,0.0226,0.0,0.0901,8.8585,2.8585
+Robert Sánchez,87.4513,2.8944,0.0011,0.143,0.0241,0.0127,9.3817,3.3817
+Ellery Balcombe,78.2609,1.8477,0.0027,0.0411,0.0,0.0616,8.6416,2.6416
+Hákon Rafn Valdimarsson,88.1709,2.3451,0.0002,0.0,0.0239,0.0,9.7967,3.7967
+Altay Bayindir,90.0,2.0842,0.0058,0.0057,0.0,0.0029,8.888,2.888
+Christos Mandas,88.7101,2.1475,0.0023,0.0,0.0,0.0,8.963,2.963
+Giorgi Mamardashvili,90.0,3.2707,0.0002,0.0212,0.002,0.1322,8.6423,2.6423
+Illan Meslier,89.8973,1.7575,0.0004,0.0258,0.0048,0.0174,8.6683,2.6683
+Mads Hermansen,89.7826,3.341,0.0012,0.0118,0.0,0.0101,9.4272,3.4272
+Gaga Slonina,90.0,3.2419,0.0002,0.0411,0.0,0.0411,9.0207,3.0207
+Djordje Petrovic,90.0,3.188,0.0002,0.0649,0.0,0.0083,9.2128,3.2128
+Robin Roefs,90.1186,3.0496,0.0034,0.0726,0.0,0.0242,9.8421,3.8421
+Bart Verbruggen,90.0,2.789,0.0014,0.1135,0.0,0.0219,9.1107,3.1107
+Senne Lammens,90.0,2.8107,0.0003,0.0055,0.0,0.0452,8.9024,2.9024
+Melker Ellborg,90.0,2.5778,0.0048,0.0319,0.0,0.0,9.9784,3.9784
+James Trafford,90.0,3.2035,0.0015,0.0451,0.0,0.0099,10.1739,4.1739
+Filip Jörgensen,88.2609,2.4271,0.0,0.0083,0.0,0.0,8.4981,2.4981
+Max Weiss,78.2609,2.2517,0.0002,0.0598,0.0,0.0199,8.6967,2.6967
+Antonín Kinsky,78.2609,2.71,0.0011,0.0,0.0,0.0,9.2558,3.2558
diff --git a/team_ratings_dual_speed.csv b/team_ratings_dual_speed.csv
new file mode 100644
index 0000000000000000000000000000000000000000..e62010338d5835b3f15895631b30df8f3f9830b7
--- /dev/null
+++ b/team_ratings_dual_speed.csv
@@ -0,0 +1,21 @@
+Team,Attack,Defence,Diff
+Arsenal,1.66,0.76,0.9
+Manchester City,1.73,0.96,0.77
+Liverpool,1.61,1.13,0.48
+Chelsea,1.61,1.16,0.46
+Manchester United,1.46,1.19,0.27
+Newcastle United,1.45,1.29,0.16
+Brighton and Hove Albion,1.32,1.17,0.15
+Brentford,1.37,1.26,0.11
+Aston Villa,1.26,1.2,0.06
+AFC Bournemouth,1.38,1.35,0.03
+Crystal Palace,1.17,1.2,-0.04
+Everton,1.08,1.11,-0.04
+Fulham,1.24,1.31,-0.07
+Nottingham Forest,1.07,1.24,-0.16
+Leeds United,1.2,1.4,-0.2
+West Ham United,1.15,1.45,-0.3
+Tottenham Hotspur,1.2,1.53,-0.33
+Sunderland,0.87,1.31,-0.44
+Wolverhampton Wanderers,0.93,1.41,-0.48
+Burnley,0.87,1.73,-0.86
diff --git a/team_totals.xlsx b/team_totals.xlsx
new file mode 100644
index 0000000000000000000000000000000000000000..0e3b3670dc6275a547ebf64cf60a4c28f3b297cc
--- /dev/null
+++ b/team_totals.xlsx
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:95bc1564798b6a98e8042a5cae8093d6d7024aaf8aeebc60eb86faea0f5659f7
+size 20972
diff --git a/user_baseline_overrides.json b/user_baseline_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..c0101036d5434d172c58ae7c2f718686510aaaa8
--- /dev/null
+++ b/user_baseline_overrides.json
@@ -0,0 +1,1382 @@
+{
+ "238": {
+ "Avg_BPS": 16.5
+ },
+ "382": {
+ "baseline_xMins": 76.0
+ },
+ "235": {
+ "baseline_xMins": 78.0
+ },
+ "381": {
+ "baseline_xMins": 77.0
+ },
+ "255": {
+ "baseline_xMins": 0.0
+ },
+ "17": {
+ "Avg_BPS": 20.5,
+ "baseline_CBITR_p90": 1.3,
+ "baseline_xMins": 66.0
+ },
+ "173": {
+ "baseline_xMins": 82.0
+ },
+ "430": {
+ "baseline_xMins": 83.0
+ },
+ "336": {
+ "baseline_xMins": 10.0
+ },
+ "385": {
+ "baseline_xMins": 4.0
+ },
+ "422": {
+ "baseline_xMins": 0.0
+ },
+ "1": {
+ "Avg_BPS": 5.9,
+ "baseline_xSaves_p90": 1.0,
+ "baseline_pksave_p90": 0.0019999999999999983,
+ "baseline_xMins": 90.0
+ },
+ "190": {
+ "baseline_xMins": 38.0
+ },
+ "195": {
+ "baseline_xMins": 58.0
+ },
+ "199": {
+ "baseline_xMins": 21.0
+ },
+ "5": {
+ "Avg_BPS": 18.3,
+ "baseline_xMins": 86.0
+ },
+ "50": {
+ "baseline_xMins": 69.0
+ },
+ "16": {
+ "Avg_BPS": 26.0,
+ "baseline_xMins": 72.0
+ },
+ "164": {
+ "baseline_xMins": 8.0
+ },
+ "168": {
+ "baseline_xMins": 5.0
+ },
+ "169": {
+ "baseline_xMins": 71.0
+ },
+ "299": {
+ "baseline_xMins": 86.0
+ },
+ "666": {
+ "Avg_BPS": 26.7,
+ "baseline_xMins": 67.0
+ },
+ "266": {
+ "Avg_BPS": 21.9,
+ "baseline_xMins": 54.0
+ },
+ "268": {
+ "baseline_xMins": 2.0
+ },
+ "64": {
+ "Avg_BPS": 25.3,
+ "baseline_xMins": 70.0
+ },
+ "370": {
+ "Avg_BPS": 21.6,
+ "baseline_xMins": 68.0
+ },
+ "376": {
+ "baseline_xMins": 55.0
+ },
+ "705": {
+ "baseline_xMins": 0.0
+ },
+ "709": {
+ "baseline_xMins": 87.0
+ },
+ "449": {
+ "baseline_xMins": 88.0
+ },
+ "442": {
+ "baseline_xMins": 84.0
+ },
+ "300": {
+ "Avg_BPS": 20.5,
+ "baseline_xMins": 24.0
+ },
+ "303": {
+ "baseline_xMins": 89.0
+ },
+ "387": {
+ "baseline_xMins": 87.0
+ },
+ "267": {
+ "baseline_xMins": 83.0
+ },
+ "48": {
+ "baseline_xG_p90": 1.0,
+ "baseline_xMins": 76.0
+ },
+ "386": {
+ "baseline_xMins": 75.0
+ },
+ "257": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 89.0
+ },
+ "570": {
+ "baseline_xMins": 63.0
+ },
+ "541": {
+ "Avg_BPS": 17.9,
+ "baseline_xMins": 84.0
+ },
+ "258": {
+ "Avg_BPS": 17.2,
+ "baseline_xMins": 81.0
+ },
+ "291": {
+ "baseline_xMins": 89.0
+ },
+ "292": {
+ "baseline_xMins": 72.0
+ },
+ "294": {
+ "baseline_xMins": 10.0
+ },
+ "568": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 82.0
+ },
+ "551": {
+ "baseline_xMins": 24.0
+ },
+ "550": {
+ "baseline_xMins": 0.0
+ },
+ "535": {
+ "baseline_xMins": 25.0
+ },
+ "8": {
+ "baseline_xMins": 84.0
+ },
+ "85": {
+ "baseline_xMins": 68.0
+ },
+ "18": {
+ "Avg_BPS": 16.5,
+ "baseline_xG_p90": 1.0,
+ "baseline_xA_p90": 1.0,
+ "baseline_xMins": 31.0
+ },
+ "180": {
+ "baseline_xMins": 0.0
+ },
+ "19": {
+ "Avg_BPS": 20.5,
+ "baseline_xG_p90": 0.8,
+ "baseline_xA_p90": 0.7
+ },
+ "22": {
+ "baseline_xG_p90": 0.75,
+ "baseline_xA_p90": 0.85,
+ "baseline_CBITR_p90": 0.8
+ },
+ "24": {
+ "baseline_xMins": 12.0
+ },
+ "30": {
+ "Avg_BPS": 22.7,
+ "baseline_xMins": 54.0
+ },
+ "38": {
+ "baseline_rc_p90": 0.0
+ },
+ "47": {
+ "Avg_BPS": 23.0764
+ },
+ "67": {
+ "baseline_pksave_p90": 0.011,
+ "baseline_xMins": 89.0
+ },
+ "72": {
+ "baseline_xMins": 89.0
+ },
+ "74": {
+ "Avg_BPS": 24.1,
+ "baseline_CBIT_p90": 1.0
+ },
+ "721": {
+ "baseline_xMins": 12.0
+ },
+ "81": {
+ "baseline_xG_p90": 1.0,
+ "baseline_xMins": 68.0
+ },
+ "90": {
+ "baseline_xMins": 75.0
+ },
+ "97": {
+ "baseline_xMins": 78.0,
+ "baseline_xG_p90": 1.0
+ },
+ "98": {
+ "Avg_BPS": 22.7,
+ "baseline_xMins": 10.0
+ },
+ "101": {
+ "baseline_pksave_p90": 0.03,
+ "baseline_xMins": 90.0
+ },
+ "105": {
+ "baseline_xMins": 0.0
+ },
+ "111": {
+ "baseline_xG_p90": 0.5,
+ "baseline_xMins": 25.0
+ },
+ "136": {
+ "baseline_xMins": 82.0
+ },
+ "143": {
+ "baseline_xMins": 14.0
+ },
+ "149": {
+ "baseline_xMins": 0.0
+ },
+ "151": {
+ "baseline_xMins": 87.0
+ },
+ "160": {
+ "baseline_xMins": 52.0
+ },
+ "123": {
+ "Avg_BPS": 22.3
+ },
+ "206": {
+ "Avg_BPS": 24.9,
+ "baseline_xMins": 52.0
+ },
+ "224": {
+ "Avg_BPS": 17.9,
+ "baseline_xMins": 75.0
+ },
+ "229": {
+ "baseline_xMins": 24.0
+ },
+ "239": {
+ "Avg_BPS": 24.1
+ },
+ "283": {
+ "baseline_xG_p90": 1.0,
+ "baseline_xMins": 35.0
+ },
+ "242": {
+ "baseline_xMins": 87.0
+ },
+ "244": {
+ "baseline_xMins": 19.0
+ },
+ "667": {
+ "Avg_BPS": 16.8,
+ "baseline_xMins": 5.0
+ },
+ "290": {
+ "baseline_xMins": 78.0
+ },
+ "329": {
+ "baseline_xMins": 83.0
+ },
+ "337": {
+ "baseline_xG_p90": 1.0,
+ "baseline_xMins": 64.0
+ },
+ "325": {
+ "baseline_xMins": 64.0
+ },
+ "324": {
+ "baseline_xMins": 87.0
+ },
+ "343": {
+ "baseline_xG_p90": 0.5,
+ "baseline_xMins": 85.0
+ },
+ "347": {
+ "baseline_xMins": 88.0
+ },
+ "362": {
+ "baseline_xMins": 2.0
+ },
+ "365": {
+ "baseline_xMins": 48.0,
+ "baseline_xG_p90": 0.9
+ },
+ "380": {
+ "baseline_xMins": 0.0
+ },
+ "390": {
+ "Avg_BPS": 19.0
+ },
+ "403": {
+ "baseline_xG_p90": 0.65,
+ "baseline_xMins": 83.0
+ },
+ "409": {
+ "baseline_xMins": 0.0
+ },
+ "436": {
+ "baseline_xG_p90": 0.5,
+ "baseline_xMins": 0.0
+ },
+ "437": {
+ "baseline_xMins": 86.0
+ },
+ "438": {
+ "baseline_xMins": 15.0
+ },
+ "441": {
+ "baseline_xG_p90": 1.05,
+ "baseline_xA_p90": 1.0,
+ "baseline_xMins": 85.0,
+ "baseline_CBIT_p90": 1.15,
+ "baseline_rc_p90": 0.0
+ },
+ "447": {
+ "baseline_xMins": 4.0
+ },
+ "469": {
+ "baseline_pksave_p90": 0.02,
+ "baseline_xMins": 2.0
+ },
+ "516": {
+ "baseline_xMins": 73.0
+ },
+ "517": {
+ "Avg_BPS": 28.9,
+ "baseline_xMins": 84.0
+ },
+ "565": {
+ "baseline_xSaves_p90": 1.24,
+ "baseline_pksave_p90": 0.019999999999999997,
+ "baseline_yc_p90": 0.5
+ },
+ "569": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_rc_p90": 0.0,
+ "baseline_xMins": 85.0
+ },
+ "571": {
+ "baseline_xMins": 15.0
+ },
+ "572": {
+ "baseline_xMins": 10.0
+ },
+ "573": {
+ "baseline_xMins": 35.0
+ },
+ "582": {
+ "baseline_rc_p90": 0.0
+ },
+ "531": {
+ "baseline_xMins": 83.0,
+ "baseline_CBIT_p90": 0.9
+ },
+ "532": {
+ "Avg_BPS": 15.0,
+ "baseline_CBIT_p90": 0.8
+ },
+ "543": {
+ "baseline_xG_p90": 0.8,
+ "baseline_xA_p90": 0.75,
+ "baseline_CBITR_p90": 0.8
+ },
+ "444": {
+ "baseline_xMins": 76.0
+ },
+ "544": {
+ "baseline_xMins": 82.0
+ },
+ "552": {
+ "baseline_xA_p90": 1.0,
+ "baseline_xG_p90": 1.0,
+ "Avg_BPS": 18.55,
+ "baseline_CBITR_p90": 0.85
+ },
+ "553": {
+ "Avg_BPS": 24.1,
+ "baseline_xG_p90": 0.5,
+ "baseline_xA_p90": 1.0,
+ "baseline_CBITR_p90": 0.8
+ },
+ "561": {
+ "Avg_BPS": 20.7,
+ "baseline_xG_p90": 1.0,
+ "baseline_xA_p90": 0.7,
+ "baseline_xMins": 26.0
+ },
+ "668": {
+ "baseline_xA_p90": 0.8,
+ "baseline_xG_p90": 0.5,
+ "baseline_rc_p90": 2.0,
+ "Avg_BPS": 19.2605
+ },
+ "628": {
+ "baseline_xMins": 90.0
+ },
+ "629": {
+ "baseline_xMins": 0.0
+ },
+ "646": {
+ "baseline_xMins": 83.0
+ },
+ "644": {
+ "baseline_xMins": 25.0
+ },
+ "677": {
+ "baseline_xMins": 64.0
+ },
+ "54": {
+ "baseline_xA_p90": 0.7,
+ "baseline_CBITR_p90": 0.7
+ },
+ "596": {
+ "Avg_BPS": 29.7,
+ "baseline_xMins": 67.0
+ },
+ "261": {
+ "baseline_xMins": 89.0
+ },
+ "262": {
+ "baseline_xMins": 11.0
+ },
+ "270": {
+ "baseline_xMins": 65.0
+ },
+ "272": {
+ "baseline_xMins": 63.0
+ },
+ "273": {
+ "baseline_xMins": 75.0
+ },
+ "135": {
+ "baseline_xMins": 23.0
+ },
+ "252": {
+ "Avg_BPS": 15.8,
+ "baseline_xMins": 25.0
+ },
+ "679": {
+ "baseline_pksave_p90": 0.02,
+ "baseline_xMins": 85.0
+ },
+ "685": {
+ "baseline_yc_p90": 0.5,
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 12.0
+ },
+ "652": {
+ "baseline_xMins": 0.0
+ },
+ "658": {
+ "baseline_xMins": 2.0
+ },
+ "683": {
+ "baseline_xMins": 86.0
+ },
+ "20": {
+ "baseline_xMins": 62.0
+ },
+ "40": {
+ "baseline_xMins": 56.0
+ },
+ "44": {
+ "baseline_xMins": 36.0
+ },
+ "59": {
+ "baseline_xMins": 69.0
+ },
+ "431": {
+ "baseline_pksave_p90": 0.0,
+ "baseline_xMins": 0.0
+ },
+ "694": {
+ "baseline_xMins": 86.0
+ },
+ "735": {
+ "baseline_xMins": 20.0
+ },
+ "734": {
+ "baseline_xMins": 18.0
+ },
+ "698": {
+ "baseline_xG_p90": 0.7,
+ "baseline_xA_p90": 0.8,
+ "baseline_xMins": 65.0
+ },
+ "125": {
+ "baseline_xMins": 70.0
+ },
+ "126": {
+ "baseline_xMins": 73.0
+ },
+ "226": {
+ "baseline_xMins": 87.0
+ },
+ "249": {
+ "baseline_xG_p90": 1.0,
+ "baseline_rc_p90": 0.0,
+ "baseline_xMins": 84.0
+ },
+ "205": {
+ "baseline_CBITR_p90": 0.8,
+ "baseline_xMins": 0.0
+ },
+ "145": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 48.0
+ },
+ "148": {
+ "baseline_xMins": 87.0
+ },
+ "700": {
+ "baseline_xG_p90": 0.4
+ },
+ "414": {
+ "baseline_CBITR_p90": 1.0,
+ "baseline_xMins": 44.0,
+ "baseline_yc_p90": 0.5
+ },
+ "736": {
+ "baseline_pksave_p90": 0.01,
+ "baseline_xMins": 90.0
+ },
+ "733": {
+ "baseline_pksave_p90": 0.02,
+ "baseline_xMins": 90.0
+ },
+ "547": {
+ "baseline_xMins": 85.0,
+ "baseline_xG_p90": 0.9
+ },
+ "457": {
+ "baseline_xMins": 76.0
+ },
+ "458": {
+ "baseline_xMins": 79.0
+ },
+ "459": {
+ "baseline_xMins": 12.0
+ },
+ "660": {
+ "baseline_xMins": 82.0
+ },
+ "342": {
+ "baseline_xG_p90": 0.5,
+ "baseline_xA_p90": 0.4,
+ "baseline_xMins": 68.0
+ },
+ "411": {
+ "baseline_xMins": 73.0
+ },
+ "6": {
+ "baseline_xMins": 85.0,
+ "baseline_CBIT_p90": 1.0
+ },
+ "32": {
+ "baseline_yc_p90": 0.5,
+ "baseline_pksave_p90": 0.02,
+ "baseline_xMins": 90.0
+ },
+ "157": {
+ "baseline_xMins": 79.0
+ },
+ "159": {
+ "baseline_xMins": 0.0
+ },
+ "198": {
+ "baseline_xMins": 75.0
+ },
+ "200": {
+ "baseline_xMins": 64.0
+ },
+ "217": {
+ "baseline_xMins": 46.0
+ },
+ "470": {
+ "baseline_xMins": 90.0,
+ "baseline_pksave_p90": 0.009286999999999998
+ },
+ "220": {
+ "baseline_xMins": 88.0,
+ "baseline_pksave_p90": 0.014733999999999999
+ },
+ "221": {
+ "baseline_xMins": 20.0
+ },
+ "712": {
+ "baseline_xMins": 56.0
+ },
+ "717": {
+ "baseline_xMins": 74.0
+ },
+ "549": {
+ "baseline_xMins": 0.0
+ },
+ "419": {
+ "baseline_xMins": 0.0,
+ "baseline_xA_p90": 0.8,
+ "baseline_xG_p90": 0.6
+ },
+ "413": {
+ "baseline_xMins": 45.0
+ },
+ "691": {
+ "baseline_xMins": 83.0
+ },
+ "367": {
+ "baseline_pksave_p90": 0.021512999999999997,
+ "baseline_xMins": 2.0
+ },
+ "407": {
+ "baseline_xMins": 83.0
+ },
+ "418": {
+ "baseline_xMins": 61.0
+ },
+ "450": {
+ "baseline_xMins": 80.0
+ },
+ "681": {
+ "baseline_xMins": 53.0
+ },
+ "682": {
+ "baseline_xMins": 32.0
+ },
+ "615": {
+ "baseline_xMins": 82.0
+ },
+ "485": {
+ "baseline_xMins": 77.0
+ },
+ "491": {
+ "baseline_xG_p90": 0.4
+ },
+ "488": {
+ "baseline_xG_p90": 0.6
+ },
+ "714": {
+ "baseline_xMins": 63.0
+ },
+ "675": {
+ "baseline_xMins": 6.0
+ },
+ "597": {
+ "baseline_xMins": 62.0
+ },
+ "673": {
+ "baseline_xMins": 82.0
+ },
+ "560": {
+ "baseline_xMins": 35.0
+ },
+ "620": {
+ "baseline_xMins": 14.0
+ },
+ "624": {
+ "baseline_xMins": 89.0
+ },
+ "627": {
+ "baseline_xMins": 4.0
+ },
+ "632": {
+ "baseline_xMins": 28.0
+ },
+ "633": {
+ "baseline_xMins": 74.0
+ },
+ "634": {
+ "baseline_xMins": 81.0
+ },
+ "635": {
+ "baseline_xMins": 57.0
+ },
+ "209": {
+ "baseline_xMins": 30.0
+ },
+ "216": {
+ "baseline_xMins": 3.0
+ },
+ "525": {
+ "baseline_xMins": 13.0
+ },
+ "637": {
+ "baseline_xMins": 37.0
+ },
+ "648": {
+ "baseline_xMins": 0.0
+ },
+ "493": {
+ "baseline_xMins": 27.0
+ },
+ "338": {
+ "baseline_xMins": 44.0
+ },
+ "335": {
+ "baseline_xMins": 38.0,
+ "baseline_xA_p90": 2.0,
+ "baseline_xG_p90": 1.5
+ },
+ "476": {
+ "baseline_xMins": 78.0
+ },
+ "479": {
+ "baseline_xMins": 0.0
+ },
+ "480": {
+ "baseline_xMins": 0.0
+ },
+ "7": {
+ "baseline_xMins": 43.0
+ },
+ "719": {
+ "baseline_xMins": 3.0
+ },
+ "77": {
+ "baseline_xMins": 85.0
+ },
+ "723": {
+ "baseline_xMins": 76.0
+ },
+ "725": {
+ "baseline_xMins": 59.0
+ },
+ "263": {
+ "baseline_xMins": 10.0
+ },
+ "158": {
+ "baseline_xMins": 42.0
+ },
+ "225": {
+ "baseline_xMins": 68.0
+ },
+ "302": {
+ "baseline_xMins": 73.0
+ },
+ "374": {
+ "baseline_xMins": 83.0
+ },
+ "384": {
+ "baseline_xMins": 68.0
+ },
+ "499": {
+ "baseline_xMins": 64.0
+ },
+ "182": {
+ "baseline_xMins": 2.0
+ },
+ "427": {
+ "baseline_xMins": 47.0
+ },
+ "423": {
+ "baseline_xMins": 41.0
+ },
+ "429": {
+ "baseline_xMins": 0.0
+ },
+ "475": {
+ "baseline_xMins": 64.0
+ },
+ "478": {
+ "baseline_xMins": 34.0
+ },
+ "600": {
+ "baseline_xMins": 14.0
+ },
+ "612": {
+ "baseline_xMins": 0.0
+ },
+ "636": {
+ "baseline_xMins": 74.0
+ },
+ "100": {
+ "baseline_xMins": 70.0,
+ "baseline_yc_p90": 0.4
+ },
+ "806": {
+ "baseline_xMins": 0.0
+ },
+ "807": {
+ "baseline_xMins": 83.0
+ },
+ "626": {
+ "baseline_xMins": 49.0
+ },
+ "178": {
+ "baseline_xMins": 73.0
+ },
+ "232": {
+ "baseline_xMins": 69.0
+ },
+ "230": {
+ "baseline_xMins": 68.0
+ },
+ "256": {
+ "baseline_xMins": 88.0
+ },
+ "264": {
+ "baseline_xMins": 0.0
+ },
+ "684": {
+ "baseline_xMins": 87.0,
+ "baseline_CBIT_p90": 1.0
+ },
+ "687": {
+ "baseline_xMins": 0.0
+ },
+ "467": {
+ "baseline_xMins": 8.0
+ },
+ "452": {
+ "baseline_xMins": 74.0
+ },
+ "804": {
+ "baseline_xMins": 14.0
+ },
+ "246": {
+ "baseline_xMins": 56.0
+ },
+ "237": {
+ "baseline_xMins": 72.0
+ },
+ "21": {
+ "baseline_xMins": 84.0
+ },
+ "83": {
+ "baseline_xMins": 78.0
+ },
+ "120": {
+ "baseline_xMins": 77.0
+ },
+ "12": {
+ "baseline_xMins": 0.0
+ },
+ "317": {
+ "baseline_xMins": 88.0
+ },
+ "417": {
+ "baseline_xMins": 68.0
+ },
+ "474": {
+ "baseline_xMins": 86.0
+ },
+ "506": {
+ "baseline_xMins": 85.0
+ },
+ "575": {
+ "baseline_xMins": 86.0
+ },
+ "2": {
+ "baseline_xMins": 1.0
+ },
+ "500": {
+ "baseline_xMins": 6.0
+ },
+ "662": {
+ "baseline_xMins": 24.0
+ },
+ "665": {
+ "baseline_xMins": 0.0
+ },
+ "33": {
+ "baseline_xMins": 0.0
+ },
+ "110": {
+ "baseline_xMins": 85.0
+ },
+ "11": {
+ "baseline_xMins": 15.0
+ },
+ "113": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 77.0
+ },
+ "152": {
+ "baseline_xMins": 21.0
+ },
+ "672": {
+ "baseline_xMins": 37.0
+ },
+ "676": {
+ "baseline_xMins": 67.0
+ },
+ "722": {
+ "baseline_xMins": 55.0
+ },
+ "322": {
+ "baseline_xMins": 85.0
+ },
+ "73": {
+ "baseline_xMins": 22.0
+ },
+ "731": {
+ "baseline_xMins": 35.0
+ },
+ "344": {
+ "baseline_xMins": 81.0
+ },
+ "346": {
+ "baseline_xMins": 0.0
+ },
+ "373": {
+ "baseline_xMins": 86.0
+ },
+ "402": {
+ "baseline_xMins": 65.0
+ },
+ "526": {
+ "baseline_xMins": 66.0
+ },
+ "716": {
+ "baseline_xMins": 33.0
+ },
+ "166": {
+ "baseline_xMins": 43.0
+ },
+ "718": {
+ "baseline_xMins": 29.0
+ },
+ "727": {
+ "baseline_xMins": 65.0
+ },
+ "695": {
+ "baseline_xMins": 52.0
+ },
+ "215": {
+ "baseline_xMins": 64.0
+ },
+ "706": {
+ "baseline_xMins": 54.0
+ },
+ "625": {
+ "baseline_xMins": 23.0
+ },
+ "653": {
+ "baseline_xMins": 0.0
+ },
+ "649": {
+ "baseline_xMins": 0.0
+ },
+ "37": {
+ "baseline_xMins": 43.0
+ },
+ "39": {
+ "baseline_xMins": 51.0
+ },
+ "41": {
+ "baseline_xMins": 57.0
+ },
+ "247": {
+ "baseline_xMins": 10.0
+ },
+ "473": {
+ "baseline_xMins": 84.0
+ },
+ "487": {
+ "baseline_xMins": 65.0
+ },
+ "720": {
+ "baseline_xMins": 52.0
+ },
+ "82": {
+ "baseline_xMins": 82.0
+ },
+ "191": {
+ "baseline_xMins": 85.0
+ },
+ "192": {
+ "baseline_xMins": 55.0
+ },
+ "661": {
+ "baseline_xMins": 71.0
+ },
+ "106": {
+ "baseline_CBIT_p90": 1.0,
+ "baseline_xMins": 76.0
+ },
+ "284": {
+ "baseline_xMins": 21.0
+ },
+ "400": {
+ "baseline_xMins": 0.0
+ },
+ "446": {
+ "baseline_xMins": 0.0
+ },
+ "713": {
+ "baseline_xMins": 69.0
+ },
+ "121": {
+ "baseline_xMins": 68.0
+ },
+ "139": {
+ "baseline_xMins": 90.0
+ },
+ "27": {
+ "baseline_xMins": 6.0
+ },
+ "316": {
+ "baseline_xMins": 48.0
+ },
+ "408": {
+ "baseline_xMins": 85.0
+ },
+ "236": {
+ "baseline_xMins": 68.0
+ },
+ "295": {
+ "baseline_xMins": 34.0
+ },
+ "440": {
+ "baseline_xMins": 82.0
+ },
+ "455": {
+ "baseline_xMins": 22.0
+ },
+ "699": {
+ "baseline_xMins": 68.0
+ },
+ "497": {
+ "baseline_xMins": 34.0
+ },
+ "107": {
+ "baseline_xMins": 53.0
+ },
+ "153": {
+ "baseline_xMins": 0.0
+ },
+ "415": {
+ "baseline_xMins": 22.0
+ },
+ "416": {
+ "baseline_xMins": 53.0
+ },
+ "310": {
+ "baseline_xMins": 71.0
+ },
+ "311": {
+ "baseline_xMins": 48.0
+ },
+ "287": {
+ "baseline_pksave_p90": 0.03
+ },
+ "119": {
+ "baseline_xMins": 84.0
+ },
+ "276": {
+ "baseline_xMins": 16.0
+ },
+ "654": {
+ "baseline_xMins": 75.0
+ },
+ "749": {
+ "baseline_xMins": 84.0
+ },
+ "730": {
+ "baseline_xMins": 68.0
+ },
+ "671": {
+ "baseline_xMins": 24.0
+ },
+ "674": {
+ "baseline_xMins": 87.0
+ },
+ "108": {
+ "baseline_xMins": 45.0
+ },
+ "580": {
+ "baseline_xMins": 42.0
+ },
+ "350": {
+ "baseline_xMins": 71.0
+ },
+ "84": {
+ "baseline_xMins": 85.0
+ },
+ "783": {
+ "baseline_xMins": 86.0
+ },
+ "728": {
+ "baseline_xMins": 23.0
+ },
+ "328": {
+ "baseline_xMins": 64.0
+ },
+ "320": {
+ "baseline_xMins": 43.0
+ },
+ "321": {
+ "baseline_xMins": 23.0
+ },
+ "791": {
+ "baseline_xMins": 80.0
+ },
+ "785": {
+ "baseline_xMins": 53.0
+ },
+ "642": {
+ "baseline_xMins": 65.0
+ },
+ "643": {
+ "baseline_xMins": 84.0
+ },
+ "36": {
+ "baseline_xMins": 86.0
+ },
+ "794": {
+ "baseline_xMins": 10.0
+ },
+ "613": {
+ "baseline_xMins": 75.0
+ },
+ "614": {
+ "baseline_xMins": 71.0
+ },
+ "726": {
+ "baseline_xMins": 54.0
+ },
+ "796": {
+ "baseline_xMins": 71.0
+ },
+ "792": {
+ "baseline_xMins": 13.0
+ },
+ "260": {
+ "baseline_xMins": 78.0
+ },
+ "697": {
+ "baseline_xMins": 54.0
+ },
+ "10": {
+ "baseline_xMins": 16.0
+ },
+ "128": {
+ "baseline_xMins": 5.0
+ },
+ "109": {
+ "baseline_xMins": 70.0
+ },
+ "138": {
+ "baseline_xMins": 0.0
+ },
+ "185": {
+ "baseline_xMins": 0.0
+ },
+ "288": {
+ "baseline_xMins": 0.0
+ },
+ "341": {
+ "baseline_xMins": 88.0
+ },
+ "510": {
+ "baseline_xMins": 20.0
+ },
+ "511": {
+ "baseline_xMins": 31.0
+ },
+ "529": {
+ "baseline_xMins": 0.0
+ },
+ "52": {
+ "baseline_xMins": 69.0
+ },
+ "566": {
+ "baseline_xMins": 0.0
+ },
+ "567": {
+ "baseline_xMins": 0.0
+ },
+ "753": {
+ "baseline_xMins": 0.0
+ },
+ "68": {
+ "baseline_xMins": 0.0
+ },
+ "254": {
+ "baseline_xMins": 0.0
+ },
+ "259": {
+ "baseline_xMins": 23.0
+ },
+ "664": {
+ "baseline_xMins": 0.0
+ },
+ "782": {
+ "baseline_xMins": 0.0
+ },
+ "715": {
+ "baseline_xMins": 0.0
+ },
+ "31": {
+ "baseline_xMins": 36.0
+ },
+ "808": {
+ "baseline_xMins": 35.0
+ },
+ "817": {
+ "baseline_xMins": 61.0,
+ "baseline_xG_p90": 0.4
+ },
+ "818": {
+ "baseline_xMins": 0.0
+ },
+ "815": {
+ "baseline_xMins": 74.0
+ },
+ "814": {
+ "baseline_xMins": 25.0
+ },
+ "813": {
+ "baseline_xMins": 51.0
+ },
+ "812": {
+ "baseline_xMins": 0.0
+ },
+ "603": {
+ "baseline_xMins": 82.0
+ },
+ "605": {
+ "baseline_xMins": 15.0
+ },
+ "606": {
+ "baseline_xMins": 76.0
+ },
+ "608": {
+ "baseline_xMins": 23.0
+ },
+ "609": {
+ "baseline_xMins": 82.0
+ },
+ "623": {
+ "baseline_xMins": 15.0
+ },
+ "659": {
+ "baseline_xMins": 27.0
+ },
+ "708": {
+ "baseline_xMins": 83.0
+ },
+ "738": {
+ "baseline_xMins": 0.0
+ },
+ "747": {
+ "baseline_xMins": 0.0
+ },
+ "231": {
+ "baseline_xMins": 32.0
+ },
+ "26": {
+ "baseline_xMins": 83.0
+ },
+ "163": {
+ "baseline_xMins": 65.0
+ },
+ "181": {
+ "baseline_xMins": 12.0
+ },
+ "801": {
+ "baseline_xMins": 4.0
+ },
+ "391": {
+ "baseline_xMins": 4.0
+ },
+ "183": {
+ "baseline_xMins": 0.0
+ },
+ "186": {
+ "baseline_xMins": 0.0
+ },
+ "187": {
+ "baseline_xMins": 77.0
+ },
+ "214": {
+ "baseline_xMins": 0.0
+ },
+ "218": {
+ "baseline_xMins": 10.0
+ },
+ "233": {
+ "baseline_xMins": 21.0
+ },
+ "339": {
+ "baseline_xMins": 0.0
+ },
+ "348": {
+ "baseline_xMins": 87.0
+ },
+ "354": {
+ "baseline_xMins": 77.0
+ },
+ "356": {
+ "baseline_xMins": 53.0
+ },
+ "366": {
+ "baseline_xMins": 90.0
+ },
+ "539": {
+ "baseline_xMins": 0.0
+ },
+ "554": {
+ "baseline_xMins": 0.0
+ },
+ "326": {
+ "baseline_xMins": 40.0
+ },
+ "210": {
+ "baseline_xMins": 16.0
+ },
+ "379": {
+ "baseline_xMins": 0.0
+ },
+ "388": {
+ "baseline_xMins": 30.0
+ },
+ "406": {
+ "baseline_xMins": 42.0
+ },
+ "410": {
+ "baseline_xMins": 16.0
+ },
+ "421": {
+ "baseline_xMins": 65.0
+ },
+ "448": {
+ "baseline_xMins": 0.0
+ },
+ "466": {
+ "baseline_xMins": 20.0
+ },
+ "477": {
+ "baseline_xMins": 79.0
+ },
+ "86": {
+ "baseline_xMins": 21.0
+ },
+ "88": {
+ "baseline_xMins": 48.0
+ },
+ "680": {
+ "baseline_xMins": 30.0
+ },
+ "250": {
+ "baseline_xMins": 20.0
+ },
+ "405": {
+ "baseline_xMins": 19.0
+ }
+}
\ No newline at end of file
diff --git a/user_player_status_overrides.json b/user_player_status_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..44d0e224f669594e03ec046dfa6df84f363a852a
--- /dev/null
+++ b/user_player_status_overrides.json
@@ -0,0 +1,1054 @@
+{
+ "383": {
+ "status": "not_a_starter"
+ },
+ "579": {
+ "status": "not_a_starter"
+ },
+ "251": {
+ "status": "not_a_starter"
+ },
+ "257": {
+ "status": "default"
+ },
+ "589": {
+ "status": "not_a_starter"
+ },
+ "581": {
+ "status": "not_a_starter"
+ },
+ "97": {
+ "status": "default"
+ },
+ "583": {
+ "status": "not_a_starter"
+ },
+ "198": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "585": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "586": {
+ "status": "injured",
+ "weeks_out": 35
+ },
+ "12": {
+ "status": "not_a_starter"
+ },
+ "13": {
+ "status": "not_a_starter"
+ },
+ "14": {
+ "status": "not_a_starter"
+ },
+ "15": {
+ "status": "not_a_starter"
+ },
+ "20": {
+ "status": "rotational_risk"
+ },
+ "27": {
+ "status": "default"
+ },
+ "28": {
+ "status": "not_a_starter"
+ },
+ "731": {
+ "status": "injured"
+ },
+ "29": {
+ "status": "not_a_starter"
+ },
+ "31": {
+ "status": "default"
+ },
+ "32": {
+ "status": "default"
+ },
+ "61": {
+ "status": "not_a_starter"
+ },
+ "66": {
+ "status": "not_a_starter"
+ },
+ "70": {
+ "status": "not_a_starter"
+ },
+ "71": {
+ "status": "not_a_starter"
+ },
+ "76": {
+ "status": "not_a_starter"
+ },
+ "75": {
+ "status": "not_a_starter"
+ },
+ "94": {
+ "status": "not_a_starter"
+ },
+ "99": {
+ "status": "not_a_starter"
+ },
+ "102": {
+ "status": "not_a_starter"
+ },
+ "103": {
+ "status": "not_a_starter"
+ },
+ "147": {
+ "status": "not_a_starter"
+ },
+ "216": {
+ "status": "default"
+ },
+ "215": {
+ "status": "injured",
+ "weeks_out": 23
+ },
+ "219": {
+ "status": "not_a_starter"
+ },
+ "223": {
+ "status": "not_a_starter"
+ },
+ "221": {
+ "status": "injured",
+ "weeks_out": 35
+ },
+ "226": {
+ "status": "injured",
+ "weeks_out": 34
+ },
+ "225": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "227": {
+ "status": "injured",
+ "weeks_out": 37
+ },
+ "224": {
+ "status": "default"
+ },
+ "240": {
+ "status": "not_a_starter"
+ },
+ "245": {
+ "status": "not_a_starter"
+ },
+ "397": {
+ "status": "not_a_starter"
+ },
+ "398": {
+ "status": "not_a_starter"
+ },
+ "412": {
+ "status": "not_a_starter"
+ },
+ "419": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "448": {
+ "status": "not_a_starter"
+ },
+ "488": {
+ "status": "injured",
+ "weeks_out": 31
+ },
+ "441": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "451": {
+ "status": "not_a_starter"
+ },
+ "453": {
+ "status": "default"
+ },
+ "454": {
+ "status": "not_a_starter"
+ },
+ "456": {
+ "status": "default"
+ },
+ "495": {
+ "status": "not_a_starter"
+ },
+ "490": {
+ "status": "default"
+ },
+ "504": {
+ "status": "not_a_starter"
+ },
+ "506": {
+ "status": "default"
+ },
+ "272": {
+ "status": "injured",
+ "weeks_out": 29
+ },
+ "502": {
+ "status": "default"
+ },
+ "520": {
+ "status": "not_a_starter"
+ },
+ "592": {
+ "status": "not_a_starter"
+ },
+ "598": {
+ "status": "not_a_starter"
+ },
+ "539": {
+ "status": "not_a_starter"
+ },
+ "615": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "179": {
+ "status": "not_a_starter"
+ },
+ "218": {
+ "status": "not_a_starter"
+ },
+ "34": {
+ "status": "not_a_starter"
+ },
+ "3": {
+ "status": "not_a_starter"
+ },
+ "4": {
+ "status": "not_a_starter"
+ },
+ "189": {
+ "status": "not_a_starter"
+ },
+ "394": {
+ "status": "not_a_starter"
+ },
+ "562": {
+ "status": "not_a_starter"
+ },
+ "23": {
+ "status": "not_a_starter"
+ },
+ "88": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "639": {
+ "status": "not_a_starter"
+ },
+ "136": {
+ "status": "default"
+ },
+ "120": {
+ "status": "default"
+ },
+ "316": {
+ "status": "default"
+ },
+ "403": {
+ "status": "injured",
+ "weeks_out": 36
+ },
+ "38": {
+ "status": "default"
+ },
+ "54": {
+ "status": "default"
+ },
+ "514": {
+ "status": "not_a_starter"
+ },
+ "58": {
+ "status": "injured",
+ "weeks_out": 35
+ },
+ "90": {
+ "status": "default"
+ },
+ "425": {
+ "status": "not_a_starter"
+ },
+ "370": {
+ "status": "injured",
+ "weeks_out": 28
+ },
+ "524": {
+ "status": "not_a_starter"
+ },
+ "503": {
+ "status": "not_a_starter"
+ },
+ "196": {
+ "status": "not_a_starter"
+ },
+ "460": {
+ "status": "not_a_starter"
+ },
+ "118": {
+ "status": "not_a_starter"
+ },
+ "428": {
+ "status": "not_a_starter"
+ },
+ "30": {
+ "status": "injured",
+ "weeks_out": 21
+ },
+ "91": {
+ "status": "not_a_starter"
+ },
+ "616": {
+ "status": "not_a_starter"
+ },
+ "112": {
+ "status": "not_a_starter"
+ },
+ "110": {
+ "status": "default"
+ },
+ "327": {
+ "status": "not_a_starter"
+ },
+ "43": {
+ "status": "not_a_starter"
+ },
+ "57": {
+ "status": "not_a_starter"
+ },
+ "574": {
+ "status": "injured",
+ "weeks_out": 30
+ },
+ "570": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "571": {
+ "status": "injured",
+ "weeks_out": 33
+ },
+ "9": {
+ "status": "not_a_starter"
+ },
+ "86": {
+ "status": "injured",
+ "weeks_out": 23
+ },
+ "161": {
+ "status": "not_a_starter"
+ },
+ "63": {
+ "status": "not_a_starter"
+ },
+ "154": {
+ "status": "not_a_starter"
+ },
+ "157": {
+ "status": "default"
+ },
+ "472": {
+ "status": "not_a_starter"
+ },
+ "484": {
+ "status": "not_a_starter"
+ },
+ "6": {
+ "status": "default"
+ },
+ "45": {
+ "status": "not_a_starter"
+ },
+ "56": {
+ "status": "not_a_starter"
+ },
+ "721": {
+ "status": "default"
+ },
+ "694": {
+ "status": "injured",
+ "weeks_out": 30
+ },
+ "730": {
+ "status": "injured",
+ "weeks_out": 30
+ },
+ "96": {
+ "status": "not_a_starter"
+ },
+ "93": {
+ "status": "not_a_starter"
+ },
+ "79": {
+ "status": "not_a_starter"
+ },
+ "150": {
+ "status": "not_a_starter"
+ },
+ "165": {
+ "status": "not_a_starter"
+ },
+ "166": {
+ "status": "injured",
+ "weeks_out": 28
+ },
+ "171": {
+ "status": "not_a_starter"
+ },
+ "168": {
+ "status": "default"
+ },
+ "184": {
+ "status": "not_a_starter"
+ },
+ "204": {
+ "status": "not_a_starter"
+ },
+ "211": {
+ "status": "not_a_starter"
+ },
+ "640": {
+ "status": "not_a_starter"
+ },
+ "234": {
+ "status": "not_a_starter"
+ },
+ "239": {
+ "status": "injured",
+ "weeks_out": 30
+ },
+ "305": {
+ "status": "not_a_starter"
+ },
+ "349": {
+ "status": "not_a_starter"
+ },
+ "357": {
+ "status": "not_a_starter"
+ },
+ "355": {
+ "status": "not_a_starter"
+ },
+ "363": {
+ "status": "not_a_starter"
+ },
+ "622": {
+ "status": "not_a_starter"
+ },
+ "607": {
+ "status": "not_a_starter"
+ },
+ "617": {
+ "status": "not_a_starter"
+ },
+ "308": {
+ "status": "not_a_starter"
+ },
+ "312": {
+ "status": "not_a_starter"
+ },
+ "704": {
+ "status": "not_a_starter"
+ },
+ "512": {
+ "status": "not_a_starter"
+ },
+ "523": {
+ "status": "not_a_starter"
+ },
+ "509": {
+ "status": "not_a_starter"
+ },
+ "655": {
+ "status": "not_a_starter"
+ },
+ "656": {
+ "status": "not_a_starter"
+ },
+ "578": {
+ "status": "not_a_starter"
+ },
+ "65": {
+ "status": "not_a_starter"
+ },
+ "333": {
+ "status": "not_a_starter"
+ },
+ "399": {
+ "status": "not_a_starter"
+ },
+ "332": {
+ "status": "injured",
+ "weeks_out": 28
+ },
+ "404": {
+ "status": "not_a_starter"
+ },
+ "465": {
+ "status": "not_a_starter"
+ },
+ "555": {
+ "status": "not_a_starter"
+ },
+ "604": {
+ "status": "not_a_starter"
+ },
+ "537": {
+ "status": "not_a_starter"
+ },
+ "545": {
+ "status": "not_a_starter"
+ },
+ "361": {
+ "status": "not_a_starter"
+ },
+ "285": {
+ "status": "not_a_starter"
+ },
+ "445": {
+ "status": "not_a_starter"
+ },
+ "542": {
+ "status": "not_a_starter"
+ },
+ "420": {
+ "status": "not_a_starter"
+ },
+ "729": {
+ "status": "not_a_starter"
+ },
+ "564": {
+ "status": "not_a_starter"
+ },
+ "267": {
+ "status": "default"
+ },
+ "262": {
+ "status": "default"
+ },
+ "265": {
+ "status": "not_a_starter"
+ },
+ "290": {
+ "status": "injured",
+ "weeks_out": 21
+ },
+ "417": {
+ "status": "default"
+ },
+ "432": {
+ "status": "not_a_starter"
+ },
+ "556": {
+ "status": "not_a_starter"
+ },
+ "559": {
+ "status": "not_a_starter"
+ },
+ "250": {
+ "status": "default"
+ },
+ "365": {
+ "status": "default"
+ },
+ "362": {
+ "status": "default"
+ },
+ "450": {
+ "status": "default"
+ },
+ "135": {
+ "status": "default"
+ },
+ "507": {
+ "status": "injured",
+ "weeks_out": 17
+ },
+ "518": {
+ "status": "not_a_starter"
+ },
+ "654": {
+ "status": "default"
+ },
+ "278": {
+ "status": "not_a_starter"
+ },
+ "122": {
+ "status": "not_a_starter"
+ },
+ "557": {
+ "status": "not_a_starter"
+ },
+ "274": {
+ "status": "not_a_starter"
+ },
+ "457": {
+ "status": "default"
+ },
+ "18": {
+ "status": "default"
+ },
+ "661": {
+ "status": "default"
+ },
+ "541": {
+ "status": "injured",
+ "weeks_out": 30
+ },
+ "610": {
+ "status": "default"
+ },
+ "322": {
+ "status": "default"
+ },
+ "366": {
+ "status": "default"
+ },
+ "477": {
+ "status": "injured",
+ "weeks_out": 28
+ },
+ "140": {
+ "status": "not_a_starter"
+ },
+ "134": {
+ "status": "not_a_starter"
+ },
+ "141": {
+ "status": "not_a_starter"
+ },
+ "142": {
+ "status": "not_a_starter"
+ },
+ "186": {
+ "status": "not_a_starter"
+ },
+ "194": {
+ "status": "not_a_starter"
+ },
+ "315": {
+ "status": "not_a_starter"
+ },
+ "340": {
+ "status": "not_a_starter"
+ },
+ "339": {
+ "status": "not_a_starter"
+ },
+ "360": {
+ "status": "not_a_starter"
+ },
+ "359": {
+ "status": "not_a_starter"
+ },
+ "364": {
+ "status": "not_a_starter"
+ },
+ "368": {
+ "status": "not_a_starter"
+ },
+ "369": {
+ "status": "not_a_starter"
+ },
+ "379": {
+ "status": "not_a_starter"
+ },
+ "433": {
+ "status": "not_a_starter"
+ },
+ "435": {
+ "status": "not_a_starter"
+ },
+ "533": {
+ "status": "not_a_starter"
+ },
+ "534": {
+ "status": "not_a_starter"
+ },
+ "535": {
+ "status": "injured",
+ "weeks_out": 31
+ },
+ "46": {
+ "status": "not_a_starter"
+ },
+ "92": {
+ "status": "not_a_starter"
+ },
+ "95": {
+ "status": "not_a_starter"
+ },
+ "115": {
+ "status": "not_a_starter"
+ },
+ "117": {
+ "status": "not_a_starter"
+ },
+ "222": {
+ "status": "not_a_starter"
+ },
+ "80": {
+ "status": "not_a_starter"
+ },
+ "22": {
+ "status": "injured",
+ "weeks_out": 36
+ },
+ "289": {
+ "status": "not_a_starter"
+ },
+ "395": {
+ "status": "not_a_starter"
+ },
+ "297": {
+ "status": "not_a_starter"
+ },
+ "51": {
+ "status": "not_a_starter"
+ },
+ "188": {
+ "status": "not_a_starter"
+ },
+ "641": {
+ "status": "not_a_starter"
+ },
+ "177": {
+ "status": "not_a_starter"
+ },
+ "576": {
+ "status": "not_a_starter"
+ },
+ "548": {
+ "status": "not_a_starter"
+ },
+ "89": {
+ "status": "not_a_starter"
+ },
+ "473": {
+ "status": "default"
+ },
+ "599": {
+ "status": "not_a_starter"
+ },
+ "528": {
+ "status": "not_a_starter"
+ },
+ "530": {
+ "status": "not_a_starter"
+ },
+ "692": {
+ "status": "not_a_starter"
+ },
+ "698": {
+ "status": "injured",
+ "weeks_out": 31
+ },
+ "212": {
+ "status": "not_a_starter"
+ },
+ "213": {
+ "status": "not_a_starter"
+ },
+ "214": {
+ "status": "not_a_starter"
+ },
+ "540": {
+ "status": "not_a_starter"
+ },
+ "657": {
+ "status": "not_a_starter"
+ },
+ "176": {
+ "status": "not_a_starter"
+ },
+ "17": {
+ "status": "injured",
+ "weeks_out": 25
+ },
+ "183": {
+ "status": "not_a_starter"
+ },
+ "201": {
+ "status": "not_a_starter"
+ },
+ "197": {
+ "status": "not_a_starter"
+ },
+ "248": {
+ "status": "not_a_starter"
+ },
+ "421": {
+ "status": "injured",
+ "weeks_out": 18
+ },
+ "681": {
+ "status": "default"
+ },
+ "338": {
+ "status": "injured",
+ "weeks_out": 25
+ },
+ "5": {
+ "status": "default"
+ },
+ "235": {
+ "status": "default"
+ },
+ "238": {
+ "status": "injured",
+ "weeks_out": 31
+ },
+ "478": {
+ "status": "injured",
+ "weeks_out": 19
+ },
+ "232": {
+ "status": "injured",
+ "weeks_out": 27
+ },
+ "482": {
+ "status": "not_a_starter"
+ },
+ "483": {
+ "status": "not_a_starter"
+ },
+ "40": {
+ "status": "injured",
+ "weeks_out": 22
+ },
+ "123": {
+ "status": "not_a_starter"
+ },
+ "393": {
+ "status": "not_a_starter"
+ },
+ "496": {
+ "status": "not_a_starter"
+ },
+ "554": {
+ "status": "not_a_starter"
+ },
+ "391": {
+ "status": "injured",
+ "weeks_out": 38
+ },
+ "436": {
+ "status": "injured",
+ "weeks_out": 34
+ },
+ "437": {
+ "status": "injured",
+ "weeks_out": 33
+ },
+ "662": {
+ "status": "injured",
+ "weeks_out": 22
+ },
+ "522": {
+ "status": "injured",
+ "weeks_out": 22
+ },
+ "475": {
+ "status": "injured",
+ "weeks_out": 18
+ },
+ "328": {
+ "status": "injured",
+ "weeks_out": 19
+ },
+ "41": {
+ "status": "default"
+ },
+ "42": {
+ "status": "not_a_starter"
+ },
+ "11": {
+ "status": "injured",
+ "weeks_out": 22
+ },
+ "700": {
+ "status": "injured",
+ "weeks_out": 21
+ },
+ "55": {
+ "status": "injured",
+ "weeks_out": 24
+ },
+ "85": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "87": {
+ "status": "default"
+ },
+ "418": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "476": {
+ "status": "default"
+ },
+ "469": {
+ "status": "default"
+ },
+ "424": {
+ "status": "injured",
+ "weeks_out": 25
+ },
+ "499": {
+ "status": "injured",
+ "weeks_out": 31
+ },
+ "498": {
+ "status": "not_a_starter"
+ },
+ "1": {
+ "status": "default"
+ },
+ "493": {
+ "status": "injured",
+ "weeks_out": 21
+ },
+ "271": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "348": {
+ "status": "default"
+ },
+ "344": {
+ "status": "injured",
+ "weeks_out": 25
+ },
+ "205": {
+ "status": "not_a_starter"
+ },
+ "485": {
+ "status": "default"
+ },
+ "284": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "525": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "696": {
+ "status": "not_a_starter"
+ },
+ "492": {
+ "status": "not_a_starter"
+ },
+ "625": {
+ "status": "not_a_starter"
+ },
+ "261": {
+ "status": "default"
+ },
+ "353": {
+ "status": "injured",
+ "weeks_out": 22
+ },
+ "408": {
+ "status": "injured",
+ "weeks_out": 23
+ },
+ "81": {
+ "status": "injured",
+ "weeks_out": 34
+ },
+ "582": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "587": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "588": {
+ "status": "injured",
+ "weeks_out": 37
+ },
+ "626": {
+ "status": "not_a_starter"
+ },
+ "269": {
+ "status": "not_a_starter"
+ },
+ "638": {
+ "status": "not_a_starter"
+ },
+ "474": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "415": {
+ "status": "injured",
+ "weeks_out": 29
+ },
+ "375": {
+ "status": "injured",
+ "weeks_out": 36
+ },
+ "377": {
+ "status": "not_a_starter"
+ },
+ "597": {
+ "status": "injured",
+ "weeks_out": 29
+ },
+ "648": {
+ "status": "not_a_starter"
+ },
+ "601": {
+ "status": "not_a_starter"
+ },
+ "342": {
+ "status": "default"
+ },
+ "25": {
+ "status": "not_a_starter"
+ },
+ "48": {
+ "status": "default"
+ },
+ "668": {
+ "status": "default"
+ },
+ "276": {
+ "status": "injured",
+ "weeks_out": 25
+ },
+ "716": {
+ "status": "injured",
+ "weeks_out": 32
+ },
+ "568": {
+ "status": "injured",
+ "weeks_out": 27
+ },
+ "735": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "637": {
+ "status": "injured",
+ "weeks_out": 26
+ },
+ "785": {
+ "status": "injured",
+ "weeks_out": 29
+ },
+ "727": {
+ "status": "injured",
+ "weeks_out": 28
+ }
+}
\ No newline at end of file
diff --git a/user_xmins_overrides.json b/user_xmins_overrides.json
new file mode 100644
index 0000000000000000000000000000000000000000..53b9cf094ccc36b2d089c5b1553c89baeaf8356c
--- /dev/null
+++ b/user_xmins_overrides.json
@@ -0,0 +1,66 @@
+{
+ "596": {
+ "1": 51,
+ "2": 73,
+ "3": 85
+ },
+ "1": {
+ "1": 88
+ },
+ "33": {
+ "1": 87
+ },
+ "32": {
+ "1": 0
+ },
+ "47": {
+ "1": 50,
+ "2": 86
+ },
+ "49": {
+ "1": 52,
+ "2": 40
+ },
+ "81": {
+ "1": 55
+ },
+ "120": {
+ "1": 64
+ },
+ "316": {
+ "1": 58,
+ "2": 70
+ },
+ "343": {
+ "1": 81
+ },
+ "371": {
+ "1": 68
+ },
+ "421": {
+ "1": 66,
+ "2": 72
+ },
+ "507": {
+ "1": 65
+ },
+ "597": {
+ "1": 76,
+ "2": 62
+ },
+ "23": {
+ "1": 13
+ },
+ "34": {
+ "1": 0
+ },
+ "661": {
+ "16": 77
+ },
+ "107": {
+ "18": 86,
+ "19": 84,
+ "20": 84,
+ "21": 83
+ }
+}
\ No newline at end of file