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 ( +
+
+
+ {/* THE RESTORED TITLE */} +
setActiveTab("solver")} + className="flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity" + > + Luigi's Mansion Logo +

+ Luigi's Mansion +

+
+ + + +
+ {isLoggedIn ? ( +
+
+
+ {userProfile.isAdmin && ( + + )} + + {userProfile.username} + +
+
+ + + + {showSettings && ( +
+ + {/* Default ID Setting */} +
+

+ Default FPL ID +

+
+ setNewDefaultId(e.target.value)} + placeholder="e.g. 123456" + className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-xs font-bold text-slate-200 outline-none focus:border-luigi-500 flex-1 shadow-inner" + /> + +
+
+ +
+ + +
+ )} +
+ ) : ( +
+ + + {hasGuestMadeEdits && ( +
+ +
+
💡
+

+ You've made custom adjustments!{" "} + setShowLoginModal(true)} + > + Log in + {" "} + to save your session. +

+
+
+
+ )} +
+ )} +
+
+
+ +
+ {/* 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 => ( + + ))} +
+ + {/* 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.

+
+
+ + +
+
+ + {/* 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 + +
+
+ + {isUsingFtList ? ( +
+ {["2", "3", "4", "5"].map((num) => ( +
+ + 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.type === "select" ? ( + + ) : ( + { + // 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 ( +
+
+ + + + + {horizonGWs.map(gw => ( + + ))} + + + + + {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]"}`} + > + + + {rowData.map((d, i) => ( + + ))} + + + + ); + })} + +
TimelineGW{gw}Total EV
+
+
+ {draft.name} +
+ {/* FIX: Native Flexbox Delete Button (always clickable, visually clean) */} + {drafts.length > 1 && ( + + )} +
+
+
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 && ( + + )} +
+ + {/* 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 && } +
+ + {/* 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" /> + +
+ )} + + {isAdmin && ( + + )} + + {/* Search Results Dropdown */} + {search && ( +
+ {searchResults.length === 0 ? ( +
No matches found...
+ ) : ( + searchResults.map(match => ( + + )) + )} +
+ )} +
+ + {/* 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} +
+ +
+ + {/* 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 ( +
+ + + {/* 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'}`} + /> + % +
+ + +
+ ); + })} + + {/* Footer: Add Row & EV Sum Validator */} +
+ + + {(() => { + 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 */} +
+ Luigi's Mansion +
+ +

+ Welcome to
+ + Luigi's Mansion + +

+ +

+ Luigi's Mansion is here. New, improved, and simply Wieffertastic. +

+ + + + {/* 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} +
+ )} + +
+ {/* Email Field */} +
+ + setEmail(e.target.value)} + className="w-full bg-slate-900 border border-slate-700 rounded-xl py-3 pl-10 pr-4 text-sm text-slate-200 focus:outline-none focus:border-luigi-400 transition-colors" + /> +
+ + {/* Password Field */} +
+ + setPassword(e.target.value)} + className="w-full bg-slate-900 border border-slate-700 rounded-xl py-3 pl-10 pr-10 text-sm text-slate-200 focus:outline-none focus:border-luigi-400 transition-colors" + /> + +
+ + {/* Confirm Password Field (Only for Sign Up) */} + {isSignUp && ( +
+ + setConfirmPassword(e.target.value)} + className={`w-full bg-slate-900 border rounded-xl py-3 pl-10 pr-10 text-sm text-slate-200 focus:outline-none transition-colors ${ + confirmPassword && password !== confirmPassword + ? "border-red-500/50 focus:border-red-500" + : "border-slate-700 focus:border-luigi-400" + }`} + /> + +
+ )} + + +
+ + {/* Toggle Login/Signup Mode */} +
+ {isSignUp ? 'Already have an account?' : "Don't have an account?"} + +
+ + {/* Elegant "OR" Divider */} +
+
+
+
+
+ OR +
+
+ + {/* 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 && ( +
+ +
+ )} + + + {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 && ( + <> + + + + )} + {onSolverUndo && ( + + )} + {player.replacedPlayer && ( + + )} +
+ +
+ {playerCardGWs.map((gw, i) => ( + + {Number(player[`${gw}_Pts`] || 0).toFixed(2)} + + ))} +
+ + {photoUrl ? ( + {player.Name} + ) : ( +
+ )} + +
+
+ {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 +
+
+ +
+
+
+ {[ + { 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} +
+ ))} +
+ +
+ + + + + + + + + + + {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 ( + + + + + + + + + {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 ( + + + + + + + ); + })} + + ); + })} + +
GWFixturexMinsProj. EV
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)} +
+ {fixLabel} ({Math.round(m.prob * 100)}%) + + updatePlayerStat(livePlayer.ID, m.id, "xMins", newVal)} + /> + + {(scaledEV * m.prob).toFixed(2)} +
+
+
+ + +
+
+
+
+ ); +}; + +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 + /> + +
+ +
+ SORT BY: + + +
+ +
+ {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 ( + + ); + })} +
+
+
+ ); +}; \ 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 + +
+
+ {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" /> + +
+ )} +
+
+ {Object.keys(sessionEdits).length > 0 && ( + + )} + +
+
+ +
+ + + + + + + + + {gameweeks.map(gw => ( + + ))} + + + + + + + {displayedPlayers.map((player) => ( + + + + + + + + + {gameweeks.map(gw => ( + + ))} + + + + + ))} + +
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"> +
Baseline
xMins
+
+
+ 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">TotalReset
{player.Pos}{player.Name}{getShortName(player.Team)}{player.BV} +
+ +
+
+
+
+ + + {/* 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] && ( + + )} +
+
+ {totalPages > 1 && ( +
+ + Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)} of {sortedAndFilteredData.length} players + +
+ + +
+
+ )} +
+ ); +} \ 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 */} +
+
+
+ + { setTeamId(e.target.value); setLastLoadedId(null); }} className="w-full bg-slate-950 border border-slate-700 rounded-lg py-1.5 pl-8 pr-3 text-xs text-slate-200 focus:outline-none focus:border-luigi-400 shadow-inner" /> +
+ +
+
+ +
+
+ {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 + +
+
+
+ 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 ( + + ); + })()} + + {/* 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 ( + + ); + })()} + + {/* 3. CHIP DROPDOWN */} +
+ Chip: + +
+ +
+
+ + + + {/* 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..." + /> + +
+ + {showDraftMenu && ( + <> +
setShowDraftMenu(false)} /> +
+ {drafts.map(d => ( + + ))} +
+ + )} +
+ + {/* CENTER: Gameweek Circles */} +
+ {horizonGWs.map((gw) => ( + + ))} +
+ + {/* RIGHT: Clone / New Draft / Delete */} + {/* RIGHT: Clone / New Draft */} +
+ + +
+
+ + + + + {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 +
+
+

Solving

+

Elapsed {solveElapsedSec}s · up to {quickSettings.iterations} iteration(s)

+
+ +
+
, document.body + )} + + {isChipSolving && createPortal( +
+
+
+
+
+
+ {/* BRANDED LOGO */} + Solving +
+
+

Chip Solving

+

Elapsed {chipSolveTimer}s

+ +
+ +
+
, document.body + )} + + {isRunningSens && createPortal( +
+
+
+
+
+
+ {/* BRANDED LOGO */} + Solving +
+
+

Sensitivity Analysis

+

Elapsed {sensTimer}s · {numSims} sims running…

+ +
+ +
+
, document.body + )} + + {showIdPrompt && ( +
+
+ +

Save as Default 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" + /> +
+ + +
+
+
+ )} +
+ ); +} \ 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

+ +
+ + {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]} +
+ ))} +
+ ) + ))} +
+ +
+ ))} +
+ )} + + {!isSolving && pendingSolutions.length === 0 && ( + appliedPlanSummary ? ( +
+
+

Last Applied · {appliedPlanSummary.horizon}

+ +
+
{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 ( + <> + +
+ {["solver", "sensitivity", "chips","fixtures"].map((tab) => ( + + ))} +
+
+ + {solverTab === "solver" && ( +
+

Quick Settings

+ + {/* TOOLBAR LAYOUT: Readable sizes, allowed to wrap if screen is small */} +
+ + {/* 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 */} +
+ + 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 */} +
+ + +
+ + {/* 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} + + ))} +
+
+ + {/* 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} + + ))} +
+
+ +
+ + +
+ )} + + {solverTab === "sensitivity" && ( +
+

+ Runs N randomised solves with per-player EV noise. +

+
+ + 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" /> +
+ + {sensResults && ( +
+
+

{sensResults.valid_runs}/{sensResults.num_sims} valid

+ +
+
+ {(sensResults.horizon_gws || []).map((gw) => { + const isChipFree = sensResults.gw_results?.[String(gw)]?.is_chip_free; + const isActive = sensViewGw === gw; + return ( + + ); + })} +
+ {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]}
+
PlayerSquadLineup
+ {rows.map((r, i) => ( +
+
{r.name}
+ {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) => ( +
+
{r.name}
+ {r.pct}% +
+ ))} +
+ ); + })} +
+ ); + })()} +
+ )} +
+ )} + + {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 ( + + ); + })} +
+
+ ); + })} +
+ + {chipSolveSolutions.length > 0 && ( +
+
+

Best Chip Combos

+ +
+ {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} + ) : ( +
+ )} + +
+
{team.Team}
+
Attack: {team.Attack.toFixed(2)}
+
Defence: {team.Defence.toFixed(2)}
+
+
+ ); + })} +
+
+
+ + {/* FULL WIDTH DATA TABLE */} +
+ + + + + + + + + + + {data.map(team => ( + + + + + + + ))} + +
TeamAttackDefenceDiff
{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