Spaces:
Running
Running
| 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,getOptimalLayout } 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({}); | |
| useEffect(() => { | |
| if (teamData.length > 0 && globalPlayers.length > 0 && availableGWs?.length > 0) { | |
| setTeamData(prevTeam => { | |
| let hasChanges = false; | |
| const newTeam = prevTeam.map(p => { | |
| if (p.isBlank) return p; | |
| const fresh = globalPlayers.find(g => String(g.ID) === String(p.ID)); | |
| if (!fresh) return p; | |
| let needsUpdate = false; | |
| for (const gw of availableGWs) { | |
| if (p[`${gw}_Pts`] !== fresh[`${gw}_Pts`] || p[`${gw}_xMins`] !== fresh[`${gw}_xMins`]) { | |
| needsUpdate = true; | |
| break; | |
| } | |
| } | |
| if (needsUpdate) { | |
| hasChanges = true; | |
| // Inherits the fresh stats while fiercely protecting the locked financial data | |
| return { ...p, ...fresh, purchase_price: p.purchase_price, selling_price: p.selling_price, Price: p.Price, now_cost: p.now_cost }; | |
| } | |
| return p; | |
| }); | |
| return hasChanges ? newTeam : prevTeam; | |
| }); | |
| } | |
| }, [globalPlayers, availableGWs]); | |
| const [pendingAutoReset, setPendingAutoReset] = useState(false); | |
| const lastOverridesRef = useRef(fixtureOverrides); | |
| // Watches for fixture changes and queues an auto-reset for when the math finishes | |
| useEffect(() => { | |
| // THE FIX: Stringify the objects so React compares the actual data, not the memory address! | |
| const currentStr = JSON.stringify(fixtureOverrides || {}); | |
| const lastStr = JSON.stringify(lastOverridesRef.current || {}); | |
| if (lastStr !== currentStr) { | |
| 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, // Absolute source of truth for stats | |
| purchase_price: knownPristineData.purchase_price, | |
| selling_price: knownPristineData.selling_price, | |
| multiplier: knownPristineData.multiplier, | |
| is_captain: knownPristineData.is_captain, | |
| is_vice_captain: knownPristineData.is_vice_captain | |
| }; | |
| hydrated.now_cost = globalMatch.now_cost !== undefined ? globalMatch.now_cost : globalMatch.Price; | |
| 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; | |
| const mLock = manualOverrides[gw]; | |
| if (mLock?.manualTransfers && Object.values(mLock.manualTransfers).some(p => String(p?.ID) === String(id))) { | |
| isChainBroken = true; break; | |
| } | |
| const sPairs = solverTransferPairs[gw]; | |
| if (sPairs && Object.values(sPairs).some(pair => String(pair.outPlayer?.ID) === String(id))) { | |
| isChainBroken = true; break; | |
| } | |
| } | |
| } else { | |
| isChainBroken = true; | |
| } | |
| // 3. APPLY THE LOGIC | |
| let finalPurchasePrice, finalSellingPrice; | |
| if (lockedBaselinePlayer && !isChainBroken) { | |
| finalPurchasePrice = lockedBaselinePlayer.purchase_price; | |
| finalSellingPrice = lockedBaselinePlayer.selling_price !== undefined ? lockedBaselinePlayer.selling_price : getPlayerPrice(lockedBaselinePlayer); | |
| } else { | |
| finalPurchasePrice = marketCost; | |
| finalSellingPrice = marketCost; | |
| } | |
| const hydrated = { | |
| ...globalMatch, // Fresh stats unconditionally overlay everything! | |
| ...(lockedBaselinePlayer && !isChainBroken ? { | |
| purchase_price: lockedBaselinePlayer.purchase_price, | |
| selling_price: lockedBaselinePlayer.selling_price, | |
| multiplier: lockedBaselinePlayer.multiplier, | |
| is_captain: lockedBaselinePlayer.is_captain, | |
| is_vice_captain: lockedBaselinePlayer.is_vice_captain | |
| } : {}), | |
| purchase_price: finalPurchasePrice, | |
| selling_price: finalSellingPrice, | |
| Price: finalSellingPrice, | |
| now_cost: marketCost | |
| }; | |
| return hydrated; | |
| }; | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [error, setError] = useState(null); | |
| const [fixtures, setFixtures] = useState([]); | |
| const [isFixturesLoaded, setIsFixturesLoaded] = useState(false); | |
| 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(() => { | |
| // THE FIX: Wait until the profile is fully hydrated (checking for .email) | |
| // and the site has finished its initial load (!isLoadingDB) before prompting. | |
| if (isLoggedIn && userProfile && userProfile.email && !userProfile.defaultTeamId && !isLoadingDB) { | |
| setShowInitialIdPrompt(true); | |
| } else { | |
| setShowInitialIdPrompt(false); | |
| } | |
| }, [isLoggedIn, userProfile, isLoadingDB]); | |
| 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 }; | |
| }; | |
| // Like getValidLayout but penalises players whose EV should be discounted due to | |
| // opposing-play clashes (two squad members facing each other in a fixture). | |
| // Falls back to getValidLayout when fixtures/settings are unavailable. | |
| const getValidLayoutWithPenalty = (players, gw, fixturesArr = fixtures, settings = comprehensiveSettings) => { | |
| if (!players || players.length !== 15) return null; | |
| const penalty = Number(settings?.opposing_play_penalty ?? DEFAULT_SETTINGS.opposing_play_penalty); | |
| // Build a map: teamId -> list of player IDs from this squad playing that GW | |
| const gwFixtures = (fixturesArr || []).filter(f => Number(f.event || f.gw) === Number(gw)); | |
| // For each player collect their team identifier (numeric or string) | |
| const getTeamId = (p) => p.team || p.Team || p.team_id; | |
| // Build a set of opposing-team pairs from GW fixtures that involve squad players | |
| // Returns adjusted EV for a player = raw EV - (penalty * number of opponents in squad) | |
| const getAdjustedEV = (p) => { | |
| if (p.isBlank) return -1000; | |
| const rawEV = Number(p[`${gw}_Pts`]) || 0; | |
| if (!penalty || !gwFixtures.length) return rawEV; | |
| const pTeam = getTeamId(p); | |
| if (!pTeam) return rawEV; | |
| // Find fixtures this player's team is in, then count squad opponents | |
| let opponentCount = 0; | |
| gwFixtures.forEach(fix => { | |
| const h = fix.team_h || fix.home_team; | |
| const a = fix.team_a || fix.away_team; | |
| // Is this player's team in this fixture? | |
| if (String(pTeam) === String(h) || String(pTeam) === String(a)) { | |
| const opponentTeam = String(pTeam) === String(h) ? String(a) : String(h); | |
| // Count squad players on the opposing team | |
| players.forEach(other => { | |
| if (other.ID !== p.ID && !other.isBlank) { | |
| const otherTeam = String(getTeamId(other)); | |
| if (otherTeam === opponentTeam) opponentCount++; | |
| } | |
| }); | |
| } | |
| }); | |
| return rawEV - penalty * opponentCount; | |
| }; | |
| let gks = players.filter((p) => p.Pos === "G").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); | |
| let defs = players.filter((p) => p.Pos === "D").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); | |
| let mids = players.filter((p) => p.Pos === "M").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(a)); | |
| let fwds = players.filter((p) => p.Pos === "F").sort((a, b) => getAdjustedEV(b) - getAdjustedEV(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) => getAdjustedEV(b) - getAdjustedEV(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) => getAdjustedEV(b) - getAdjustedEV(a)); | |
| const bench = benchGk ? [benchGk, ...benchRest] : benchRest; | |
| const topStarters = [...finalStarters].sort((a, b) => getAdjustedEV(b) - getAdjustedEV(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(data => { setFixtures(data); setIsFixturesLoaded(true); }) | |
| .catch(() => { setIsFixturesLoaded(true); }); | |
| 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]); | |
| const hasAutoLoadedAfterLogin = useRef(false); | |
| useEffect(() => { | |
| if (isLoggedIn && userProfile.defaultTeamId && String(userProfile.defaultTeamId) === String(teamId) && !isLoading) { | |
| if (!hasAutoLoadedAfterLogin.current) { | |
| hasAutoLoadedAfterLogin.current = true; | |
| fetchTeam(null, teamData.length > 0); | |
| } | |
| } | |
| if (!isLoggedIn) { | |
| hasAutoLoadedAfterLogin.current = false; // reset on logout | |
| } | |
| }, [isLoggedIn, userProfile.defaultTeamId, teamId]); | |
| 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 token = localStorage.getItem('fpl_token'); | |
| const res = await fetch(`https://anayshukla-fpl-solver.hf.space/api/manager/${teamId}`, { | |
| headers: token ? { 'Authorization': `Bearer ${token}` } : {} | |
| }); | |
| 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]); | |
| // THE HYDRATION FIX: Merge the raw FPL picks with the rich Python math BEFORE rendering the pitch! | |
| const hydratedPicks = data.picks.map(p => { | |
| const gMatch = globalPlayers.find(g => String(g.ID) === String(p.ID)); | |
| if (gMatch) { | |
| // Attach all EV, Fixture, and Price data instantly | |
| return { ...gMatch, ...p, Price: p.selling_price !== undefined ? p.selling_price : gMatch.Price }; | |
| } | |
| return p; | |
| }); | |
| // Now getValidLayoutWithPenalty has the EV data + opposing-play penalty to sort properly! | |
| const opt = getValidLayoutWithPenalty(hydratedPicks, gws[0], fixtures, comprehensiveSettings); | |
| if (opt) { setTeamData(opt.optimalArray); setCaptainId(opt.cap); setViceId(opt.vice); } | |
| else { setTeamData(hydratedPicks); } | |
| } 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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 { | |
| // THE FIX: Read dynamically from the UI settings! | |
| const rawBw = comprehensiveSettings?.bench_weights || { 0: 0.03, 1: 0.21, 2: 0.06, 3: 0.002 }; | |
| const gkWeight = Number(rawBw[0] || 0.03); | |
| const outWeights = [Number(rawBw[1] || 0.21), Number(rawBw[2] || 0.06), Number(rawBw[3] || 0.002)]; | |
| if (p.Pos === "G") { | |
| total += (Number(p[`${activeGW}_Pts`]) || 0) * gkWeight; | |
| } else { | |
| const bw = outWeights[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 { | |
| // THE FIX: Dynamic settings for horizon EV too! | |
| const rawBw = comprehensiveSettings?.bench_weights || { 0: 0.03, 1: 0.21, 2: 0.06, 3: 0.002 }; | |
| const gkWeight = Number(rawBw[0] || 0.03); | |
| const outWeights = [Number(rawBw[1] || 0.21), Number(rawBw[2] || 0.06), Number(rawBw[3] || 0.002)]; | |
| if (p.Pos === "G") { | |
| gwPts += (Number(p[`${gw}_Pts`]) || 0) * gkWeight; | |
| } else { | |
| const bw = outWeights[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 = getValidLayoutWithPenalty(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 }; | |
| let runningItb = baselineItb; | |
| const startIndex = availableGWs.indexOf(sol.horizon_gws[0]); | |
| if (startIndex > 0) { | |
| for (let i = 0; i < startIndex; i++) { | |
| const gw = availableGWs[i]; | |
| if (chipsByGw[gw] === "fh") continue; | |
| if (transfersByGw[gw]) runningItb += transfersByGw[gw].netDelta || 0; | |
| } | |
| } | |
| const fixedPlanForSummary = []; | |
| 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 isSymmetricChip = sol.chips_used && (sol.chips_used[String(gw)] === "bb" || sol.chips_used[String(gw)] === "fh"); | |
| // BUG 2 FIX: Auto-optimize the BB lineup visually so best players start, | |
| // using the opposing-play penalty for accurate EV ranking. | |
| if (isSymmetricChip) { | |
| const all15 = [...activeLineup, ...activeBench]; | |
| const pObjs = all15.map(id => { | |
| const p = globalPlayers.find(x => String(x.ID) === String(id)); | |
| return p ? { ...p } : { ID: id, Pos: 'M' }; | |
| }).filter(Boolean); | |
| const opt = getValidLayoutWithPenalty(pObjs, gw); | |
| if (opt) { | |
| const starterSet = new Set(opt.optimalArray.slice(0, 11).map(p => String(p.ID))); | |
| activeLineup = opt.optimalArray.slice(0, 11).map(p => p.ID); | |
| activeBench = opt.optimalArray.slice(11).map(p => p.ID); | |
| } | |
| } | |
| 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)]; | |
| let currentNetDelta = 0; // Track this GW's net delta | |
| if (gwPlan.transfers_in.length > 0 || gwPlan.transfers_out.length > 0) { | |
| // THE RE-APPLY FIX: Use hydratePlayer to pull the permanent locked squad prices, NEVER the current UI teamData! | |
| currentNetDelta = gwPlan.transfers_out.reduce((sum, id) => { | |
| const p = hydratePlayer(id); | |
| return sum + (p ? p.Price : 0); | |
| }, 0) - gwPlan.transfers_in.reduce((sum, id) => { | |
| const p = hydratePlayer(id); | |
| return sum + (p ? p.now_cost : 0); | |
| }, 0); | |
| newTransfersByGw[gw] = { count: gwPlan.transfers_in.length, hits: gwPlan.hits, netDelta: currentNetDelta, 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 outP = hydratePlayer(outId); // Always fetches the accurate original player! | |
| if (outP) newManualTransfersForGw[inId] = outP; | |
| }); | |
| // 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 | |
| newOverrides[gw] = { | |
| ...newOverrides[gw], | |
| manualTransfers: { | |
| ...(newOverrides[gw]?.manualTransfers || {}), | |
| ...newManualTransfersForGw | |
| } | |
| }; | |
| if (gwPlan.chip) newChipsByGw[gw] = gwPlan.chip; | |
| } else { | |
| delete newTransfersByGw[gw]; | |
| delete newHighlights[gw]; | |
| delete newPairs[gw]; | |
| } | |
| let activeItbThisGw = runningItb + currentNetDelta; | |
| if (gwPlan.chip !== "fh") { | |
| runningItb += currentNetDelta; | |
| } | |
| fixedPlanForSummary.push({ ...gwPlan, itb: activeItbThisGw }); | |
| }); | |
| 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: fixedPlanForSummary, | |
| 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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 = getValidLayoutWithPenalty(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 ( | |
| <div className="flex flex-col w-full h-full pb-10"> | |
| {/* Minimal Top Bar for Load */} | |
| <div className="w-full flex justify-end mb-4 z-dropdown"> | |
| <form onSubmit={fetchTeam} className="flex gap-2 items-center bg-slate-900/40 px-4 py-2 rounded-xl border border-slate-800 backdrop-blur-sm shadow-xl"> | |
| <div className="relative w-48"> | |
| <Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" size={14} /> | |
| <input type="text" placeholder="FPL Team ID..." value={teamId} onChange={(e) => { setTeamId(e.target.value); setLastLoadedId(null); }} className="w-full bg-slate-950 border border-slate-700 rounded-lg py-1.5 pl-8 pr-3 text-xs text-slate-200 focus:outline-none focus:border-luigi-400 shadow-inner" /> | |
| </div> | |
| <button type="submit" disabled={isLoading || (teamData.length > 0 && teamId === lastLoadedId)} className="bg-luigi-500 hover:bg-luigi-400 text-slate-950 font-bold px-3 py-1.5 rounded-lg text-xs flex items-center gap-1.5 shadow-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"> | |
| {isLoading ? <Loader2 size={14} className="animate-spin" /> : "Load"} | |
| </button> | |
| </form> | |
| </div> | |
| <div className="flex flex-col xl:flex-row gap-8 w-full"> | |
| <div className="w-full xl:w-[72%] flex flex-col gap-4 xl:-mt-12 relative z-base"> | |
| {teamData.length > 0 && isFixturesLoaded ? ( | |
| <> | |
| {/* Pitch Rendering wrapper */} | |
| <DndContext sensors={sensors} collisionDetection={closestCenter} onDragStart={handleDragStart} onDragEnd={handleDragEnd}> | |
| <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 bg-slate-900/30 p-3 rounded-xl border border-slate-800/50 mb-2"> | |
| {/* STATS ROW - Clean spacing, non-wrapping to prevent jitter */} | |
| <div className="flex items-center gap-4 w-full overflow-x-auto hide-scrollbar"> | |
| <div className="flex flex-col"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase">ITB</span> | |
| <span className="text-sm font-mono font-bold text-emerald-400 tabular-nums">£{Math.abs(itb) < 0.05 ? "0.0" : itb.toFixed(1)}m</span> | |
| </div> | |
| <div className="flex flex-col"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase">FT</span> | |
| <span className="text-sm font-mono font-bold text-cyan-400 leading-tight whitespace-nowrap"> | |
| {(() => { | |
| const chip = chipsByGw[activeGW]; const T = transfersByGw[activeGW]?.count ?? 0; | |
| if (chip === "wc") return <><span className="text-yellow-400 text-xs font-black">⚡ WC</span> <span className="text-slate-500 text-[10px]">({T}/∞)</span></>; | |
| if (chip === "fh") return <><span className="text-orange-400 text-xs font-black">↩ FH</span> <span className="text-slate-500 text-[10px]">({T}/∞)</span></>; | |
| return `${T} / ${ftAtStartOfGw(activeGW, availableGWs, baselineFt, transfersByGw, chipsByGw)}${hitsThisGw > 0 ? ` (-${hitsThisGw * 4} pts)` : ""}`; | |
| })()} | |
| </span> | |
| </div> | |
| <div className="flex flex-col"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase">Horizon</span> | |
| <select value={horizon} onChange={(e) => setHorizon(Number(e.target.value))} className="bg-transparent text-sm font-mono font-bold text-luigi-400 outline-none cursor-pointer"> | |
| {Array.from({ length: maxAvailableHorizon }, (_, i) => i + 1).map((h) => (<option key={h} value={h} className="bg-slate-900">{h} {h === 1 ? "GW" : "GWs"}</option>))} | |
| </select> | |
| </div> | |
| <div className="h-8 w-px bg-slate-700 hidden sm:block"></div> | |
| <div className="flex flex-col"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase whitespace-nowrap">GW {activeGW} EV</span> | |
| <span className="text-sm font-mono font-bold text-cyan-400 tabular-nums">{activeGwEV.toFixed(2)}</span> | |
| </div> | |
| {horizonGWs.length > 1 && ( | |
| <div className="flex flex-col"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase whitespace-nowrap">Horizon EV</span> | |
| <span className="text-sm font-mono font-bold text-emerald-400 tabular-nums">{horizonEV.toFixed(2)}</span> | |
| </div> | |
| )} | |
| </div> | |
| {/* BUTTON ROW - Pushed to the right, strictly locked nowrap */} | |
| <div className="flex flex-nowrap items-center justify-between gap-2 w-full"> | |
| {/* 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 ( | |
| <button | |
| type="button" | |
| onClick={handleResetGWTransfers} | |
| disabled={!canReset} | |
| className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-black uppercase tracking-wider transition-all shadow-lg shrink-0 whitespace-nowrap hover:bg-red-500/20 hover:border-red-500/40 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-red-500/10 disabled:hover:border-red-500/20 disabled:active:scale-100 disabled:shadow-none" | |
| > | |
| <RotateCcw size={12} /> Reset | |
| </button> | |
| ); | |
| })()} | |
| {/* 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 = getValidLayoutWithPenalty(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 ( | |
| <button | |
| onClick={handleResetGW} | |
| disabled={!canResetLineup} | |
| className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-luigi-500/10 border border-luigi-500/20 text-luigi-400 text-[10px] font-black uppercase tracking-wider transition-all shadow-lg shrink-0 whitespace-nowrap hover:bg-luigi-500/20 hover:border-luigi-500/40 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-luigi-500/10 disabled:hover:border-luigi-500/20 disabled:active:scale-100 disabled:shadow-none" | |
| title="Reset Lineup to Optimal" | |
| > | |
| <RotateCcw size={12} /> Reset Lineup | |
| </button> | |
| ); | |
| })()} | |
| {/* 3. CHIP DROPDOWN */} | |
| <div className="flex items-center gap-1.5 shrink-0"> | |
| <span className="text-[10px] font-black text-slate-500 uppercase tracking-wider">Chip:</span> | |
| <select value={chipsByGw[activeGW] || ""} onChange={(e) => handleChipSelect(activeGW, e.target.value || null)} className="bg-slate-900 border border-slate-700 rounded px-2 py-1 text-xs font-bold text-slate-300 focus:outline-none cursor-pointer focus:border-luigi-500"> | |
| <option value="">None</option><option value="wc">⚡ WC</option><option value="fh">↩ FH</option><option value="bb">⬆ BB</option><option value="tc">✕3 TC</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| <DraftsComparisonTable | |
| drafts={drafts} | |
| horizonGWs={horizonGWs} | |
| activeDraftId={activeDraftId} | |
| globalPlayers={globalPlayers} | |
| setActiveDraftId={setActiveDraftId} | |
| getValidLayout={getValidLayoutWithPenalty} | |
| availableGWs={availableGWs} | |
| setDrafts={setDrafts} | |
| baselineFt={baselineFt} | |
| baselineItb={baselineItb} | |
| ftAtStartOfGw={ftAtStartOfGw} | |
| advancedSettings={advancedSettings} | |
| /> | |
| {/* MULTIVERSE TIMELINE CONTROL BAR */} | |
| <div className="flex flex-col md:flex-row items-center justify-between gap-2 bg-slate-900/80 p-2 rounded-xl border border-[#2a2d5c] backdrop-blur-md shadow-lg mb-3 relative z-dropdown"> | |
| {/* LEFT: Custom Editable Dropdown Box */} | |
| <div className="relative w-full md:w-56 shrink-0 z-popover"> | |
| <div className="flex items-center bg-[#0a0f1c] border border-[#2a2d5c] rounded-lg overflow-hidden shadow-[inset_0_2px_10px_rgba(0,0,0,0.5)] focus-within:border-indigo-500 transition-colors h-8"> | |
| <input | |
| type="text" | |
| value={drafts.find(d => 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..." | |
| /> | |
| <button | |
| onClick={() => setShowDraftMenu(!showDraftMenu)} | |
| className="px-2.5 h-full flex items-center justify-center bg-[#151833] border-l border-[#2a2d5c] hover:bg-[#1e2247] transition-colors" | |
| > | |
| <span className="text-indigo-400 text-[8px]">▼</span> | |
| </button> | |
| </div> | |
| {showDraftMenu && ( | |
| <> | |
| <div className="fixed inset-0 z-modal-backdrop" onClick={() => setShowDraftMenu(false)} /> | |
| <div className="absolute top-full left-0 mt-1.5 w-full bg-[#0a0f1c] border border-[#2a2d5c] rounded-lg shadow-[0_10px_40px_rgba(0,0,0,0.8)] overflow-hidden py-1 z-popover"> | |
| {drafts.map(d => ( | |
| <button | |
| key={d.id} | |
| onClick={() => { setActiveDraftId(d.id); setShowDraftMenu(false); }} | |
| className={`w-full text-left px-3 py-2 text-[11px] font-bold transition-colors ${d.id === activeDraftId ? "bg-indigo-500/20 text-indigo-300" : "text-slate-400 hover:bg-[#151833] hover:text-slate-200"}`} | |
| > | |
| {d.name} | |
| </button> | |
| ))} | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* CENTER: Gameweek Circles */} | |
| <div className="flex gap-1.5 flex-wrap justify-center flex-1"> | |
| {horizonGWs.map((gw) => ( | |
| <button key={gw} type="button" onClick={() => setActiveGW(gw)} className={`small-touch-target relative w-7 h-7 rounded-full flex items-center justify-center text-[11px] font-bold transition-all ${activeGW === gw ? "bg-luigi-500 text-slate-950 scale-110 shadow-[0_0_10px_rgba(16,185,129,0.5)]" : "bg-slate-800 text-slate-400 hover:bg-slate-700 border border-slate-700"}`}> | |
| {gw} | |
| {chipsByGw[gw] && (<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${CHIP_CONFIG[chipsByGw[gw]].dot} flex items-center justify-center text-[6px] font-black text-slate-950 border border-slate-950 leading-none`} title={CHIP_CONFIG[chipsByGw[gw]].label}>{CHIP_CONFIG[chipsByGw[gw]].short[0]}</span>)} | |
| </button> | |
| ))} | |
| </div> | |
| {/* RIGHT: Clone / New Draft / Delete */} | |
| {/* RIGHT: Clone / New Draft */} | |
| <div className="flex items-center justify-end gap-1.5 w-full md:w-auto shrink-0"> | |
| <button onClick={handleCloneDraft} disabled={drafts.length >= 5} className="small-touch-target flex items-center gap-1.5 px-3 py-1.5 bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 border border-indigo-500/20 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed" title="Clone reality"> | |
| <Copy size={12} /> Clone | |
| </button> | |
| <button onClick={handleNewDraft} disabled={drafts.length >= 5} className="small-touch-target flex items-center gap-1.5 px-3 py-1.5 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/20 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed" title="New timeline"> | |
| <Plus size={12} /> New | |
| </button> | |
| </div> | |
| </div> | |
| <PitchView | |
| teamData={renderTeamData} | |
| activeDragPlayer={activeDragPlayer} | |
| isValidSwap={isValidSwap} | |
| captainId={captainId} | |
| viceId={viceId} | |
| handleCapChange={handleCapChange} | |
| playerCardGWs={playerCardGWs} | |
| fixtures={fixtures} | |
| activeGW={activeGW} | |
| setSelectedPlayer={setSelectedPlayer} | |
| handleUndoTransfer={handleUndoTransfer} | |
| highlightTransferIds={highlightTransferIds} | |
| solverTransferPairs={solverTransferPairs} | |
| resetHighlightedTransfer={resetHighlightedTransfer} | |
| chipsByGw={chipsByGw} | |
| /> | |
| <DragOverlay dropAnimation={null}> | |
| {activeDragPlayer && !activeDragPlayer.isBlank ? ( | |
| <PlayerCardVisual player={activeDragPlayer} isBench={false} captainId={captainId} viceId={viceId} playerCardGWs={playerCardGWs} fixtures={fixtures} activeGW={activeGW} /> | |
| ) : null} | |
| </DragOverlay> | |
| </DndContext> | |
| </> | |
| ) : ( | |
| <div className="w-full min-h-[500px] border-2 border-dashed border-slate-800 rounded-2xl flex items-center justify-center text-slate-500 bg-[#0a3a2a]/30 flex-col gap-4 relative overflow-hidden"> | |
| {isLoadingDB || isLoading ? ( | |
| <> | |
| <div className="absolute inset-0 pointer-events-none flex flex-col items-center justify-evenly py-12 px-8"> | |
| {[1, 4, 4, 2].map((count, rowIdx) => ( | |
| <div key={rowIdx} className="flex justify-center gap-6 sm:gap-10"> | |
| {Array.from({ length: count }).map((_, i) => ( | |
| <div key={i} className="w-[52px] sm:w-[68px] h-[72px] sm:h-[92px] rounded-xl bg-slate-800/50 skeleton-pulse" style={{ animationDelay: `${(rowIdx * count + i) * 0.12}s` }} /> | |
| ))} | |
| </div> | |
| ))} | |
| </div> | |
| <Loader2 size={32} className="animate-spin text-emerald-500 z-base" /> | |
| <span className="z-base text-sm font-bold text-slate-400">{isLoading ? "Loading squad..." : "Booting Global Engine..."}</span> | |
| </> | |
| ) : ( | |
| "Enter your FPL ID above to load your squad." | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Right Column */} | |
| <div className="w-full xl:w-[28%] flex flex-col gap-4"> | |
| {/* NEW HOME FOR TABS PANEL */} | |
| <div className="rounded-2xl border border-slate-700/50 bg-slate-950/80 backdrop-blur-md shadow-xl overflow-hidden relative shrink-0"> | |
| <TabsPanel | |
| solverTab={solverTab} | |
| setSolverTab={setSolverTab} | |
| isSolving={isSolving} | |
| isRunningSens={isRunningSens} | |
| isChipSolving={isChipSolving} | |
| runMainSolver={runMainSolver} | |
| runSensAnalysis={runSensAnalysis} | |
| runChipSolve={runChipSolve} | |
| setShowAdvancedSettings={setShowAdvancedSettings} | |
| quickSettings={quickSettings} | |
| setQuickSettings={setQuickSettings} | |
| banSearch={banSearch} | |
| setBanSearch={setBanSearch} | |
| lockSearch={lockSearch} | |
| setLockSearch={setLockSearch} | |
| globalPlayers={globalPlayers} | |
| teamData={renderTeamData} | |
| solveGWLabel={solveGWLabel} | |
| numSims={numSims} | |
| setNumSims={setNumSims} | |
| sensResults={sensResults} | |
| setSensResults={setSensResults} | |
| sensViewGw={sensViewGw} | |
| setSensViewGw={setSensViewGw} | |
| chipSolveOptions={chipSolveOptions} | |
| setChipSolveOptions={setChipSolveOptions} | |
| chipSolveSolutions={chipSolveSolutions} | |
| setChipSolveSolutions={setChipSolveSolutions} | |
| horizonGWs={horizonGWs} | |
| baselineEv={horizonEV} | |
| /> | |
| </div> | |
| <ActiveMovesPanel | |
| activeGW={activeGW} | |
| manualOverrides={manualOverrides} | |
| globalPlayers={globalPlayers} | |
| chipsByGw={chipsByGw} | |
| transfersByGw={transfersByGw} | |
| /> | |
| <SolverOutputPanel | |
| pendingSolutions={pendingSolutions} | |
| setPendingSolutions={setPendingSolutions} | |
| isSolving={isSolving} | |
| globalPlayers={globalPlayers} | |
| applySolution={applySolution} | |
| appliedPlanSummary={appliedPlanSummary} | |
| setAppliedPlanSummary={setAppliedPlanSummary} | |
| baselineEv={horizonEV} | |
| comprehensiveSettings={comprehensiveSettings} | |
| /> | |
| </div> | |
| </div> | |
| {/* MODALS */} | |
| {selectedPlayer && !selectedPlayer.isBlank && ( | |
| <PlayerEditModal | |
| selectedPlayer={selectedPlayer} setSelectedPlayer={setSelectedPlayer} activeGW={activeGW} horizonGWs={horizonGWs} updatePlayerStat={updatePlayerStat} handleTransferOut={handleTransferOut} fixtures={fixtures} fixtureOverrides={fixtureOverrides} sessionEdits={sessionEdits} globalPlayers={globalPlayers} | |
| /> | |
| )} | |
| {selectedPlayer && selectedPlayer.isBlank && ( | |
| <PlayerSearchModal | |
| selectedPlayer={selectedPlayer} setSelectedPlayer={setSelectedPlayer} searchQuery={searchQuery} setSearchQuery={setSearchQuery} sortConfig={sortConfig} setSortConfig={setSortConfig} globalPlayers={globalPlayers} ownedPlayerIds={ownedPlayerIds} activeGW={activeGW} itb={itb} handleAddPlayer={handleAddPlayer} | |
| /> | |
| )} | |
| {showAdvancedSettings && ( | |
| <AdvancedSettingsModal | |
| setShowAdvancedSettings={setShowAdvancedSettings} comprehensiveSettings={comprehensiveSettings} setComprehensiveSettings={setComprehensiveSettings} advancedSettings={advancedSettings} setAdvancedSettings={setAdvancedSettings} | |
| /> | |
| )} | |
| {/* LOADING PORTALS */} | |
| {isSolving && createPortal( | |
| <div className="fixed inset-0 z-modal flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6"> | |
| <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(16,185,129,0.14),transparent_50%)]" /> | |
| <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-luigi-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(16,185,129,0.15)]"> | |
| <div className="relative flex h-32 w-32 items-center justify-center"> | |
| <div className="absolute inset-0 rounded-full border-4 border-slate-800" /> | |
| <div className="absolute inset-0 animate-spin rounded-full border-4 border-luigi-500 border-t-transparent" /> | |
| {/* BRANDED LOGO */} | |
| <img src="/l-logo.png" alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(16,185,129,0.6)]" /> | |
| </div> | |
| <div className="text-center"> | |
| <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-luigi-400">Solving</p> | |
| <p className="font-mono text-xs text-slate-400">Elapsed {solveElapsedSec}s · up to {quickSettings.iterations} iteration(s)</p> | |
| </div> | |
| <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button> | |
| </div> | |
| </div>, document.body | |
| )} | |
| {isChipSolving && createPortal( | |
| <div className="fixed inset-0 z-modal flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6"> | |
| <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(168,85,247,0.14),transparent_50%)]" /> | |
| <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-purple-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(168,85,247,0.15)]"> | |
| <div className="relative flex h-32 w-32 items-center justify-center"> | |
| <div className="absolute inset-0 rounded-full border-4 border-slate-800" /> | |
| <div className="absolute inset-0 animate-spin rounded-full border-4 border-purple-500 border-t-transparent" /> | |
| {/* BRANDED LOGO */} | |
| <img src="l-logo.png" alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(168,85,247,0.6)]" /> | |
| </div> | |
| <div className="text-center"> | |
| <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-purple-400">Chip Solving</p> | |
| <p className="font-mono text-xs text-slate-400">Elapsed {chipSolveTimer}s</p> | |
| </div> | |
| <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button> | |
| </div> | |
| </div>, document.body | |
| )} | |
| {isRunningSens && createPortal( | |
| <div className="fixed inset-0 z-modal flex flex-col items-center justify-center bg-slate-950/80 backdrop-blur-md p-6"> | |
| <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_35%,rgba(6,182,212,0.14),transparent_50%)]" /> | |
| <div className="relative flex max-w-md flex-col items-center gap-8 rounded-2xl border border-cyan-500/20 bg-slate-950/90 px-10 py-12 shadow-[0_0_60px_rgba(6,182,212,0.15)]"> | |
| <div className="relative flex h-32 w-32 items-center justify-center"> | |
| <div className="absolute inset-0 rounded-full border-4 border-slate-800" /> | |
| <div className="absolute inset-0 animate-spin rounded-full border-4 border-cyan-500 border-t-transparent" /> | |
| {/* BRANDED LOGO */} | |
| <img src="l-logo.png" alt="Solving" className="relative w-12 h-12 object-contain animate-pulse drop-shadow-[0_0_15px_rgba(6,182,212,0.6)]" /> | |
| </div> | |
| <div className="text-center"> | |
| <p className="mb-2 text-lg font-black uppercase tracking-[0.2em] text-cyan-400">Sensitivity Analysis</p> | |
| <p className="font-mono text-xs text-slate-400">Elapsed {sensTimer}s · {numSims} sims running…</p> | |
| </div> | |
| <button type="button" onClick={() => abortControllerRef.current?.abort()} className="mt-4 px-6 py-2 rounded-xl bg-slate-900 border border-slate-700 text-slate-400 hover:text-white hover:border-slate-500 font-bold transition-all text-sm">Cancel Solve</button> | |
| </div> | |
| </div>, document.body | |
| )} | |
| {showIdPrompt && ( | |
| <div className="fixed inset-0 z-modal flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> | |
| <div className="bg-slate-950 border border-slate-800 w-full max-w-sm rounded-2xl p-6 flex flex-col items-center"> | |
| <Shield size={24} className="text-luigi-400 mb-4" /> | |
| <h3 className="text-xl font-black text-slate-100 mb-2">Save as Default ID?</h3> | |
| <div className="flex gap-3 w-full mt-4"> | |
| <button onClick={() => setShowIdPrompt(false)} className="flex-1 bg-slate-900 text-slate-300 py-2.5 rounded-xl border border-slate-700">Not Now</button> | |
| <button onClick={() => { setUserProfile((prev) => ({ ...prev, defaultTeamId: pendingTeamId })); setShowIdPrompt(false); }} className="flex-1 bg-luigi-500 text-slate-950 py-2.5 rounded-xl font-bold">Save ID</button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {/* INITIAL LOGIN ID PROMPT */} | |
| {showInitialIdPrompt && ( | |
| <div className="fixed inset-0 z-critical flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"> | |
| <div className="bg-slate-950 border border-slate-800 w-full max-w-sm rounded-2xl p-6 flex flex-col items-center shadow-[0_0_40px_rgba(16,185,129,0.1)]"> | |
| <Shield size={32} className="text-emerald-500 mb-4" /> | |
| <h3 className="text-xl font-black text-slate-100 mb-2">Welcome!</h3> | |
| <p className="text-xs text-slate-400 text-center mb-6">Enter your FPL Team ID to set it as your default for future logins.</p> | |
| <input | |
| type="number" | |
| value={initialIdInput} | |
| onChange={(e) => 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" | |
| /> | |
| <div className="flex gap-3 w-full"> | |
| <button onClick={() => setShowInitialIdPrompt(false)} className="flex-1 bg-slate-900 text-slate-400 py-2.5 rounded-xl border border-slate-700 hover:text-slate-300 transition-colors text-sm font-bold">Skip</button> | |
| <button onClick={handleSaveInitialId} className="flex-1 bg-emerald-500 text-slate-950 py-2.5 rounded-xl font-black hover:bg-emerald-400 transition-colors shadow-lg text-sm">Save Default ID</button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } |