fpl-solver / frontend /src /components /TabsPanel.jsx
AnayShukla's picture
updates
052f3f2
import React, { useContext, useState } from "react";
import { Settings } from "lucide-react";
import { CHIP_CONFIG } from "../utils/fplLogic";
import { PlayerContext } from "../PlayerContext";
import { FixtureMatrixPanel } from "./FixtureMatrixPanel";
export const TabsPanel = ({
solverTab, setSolverTab,
isSolving, isRunningSens, isChipSolving,
runMainSolver, runSensAnalysis, runChipSolve,
setShowAdvancedSettings,
quickSettings, setQuickSettings,
banSearch, setBanSearch, lockSearch, setLockSearch,
globalPlayers, teamData, solveGWLabel,
numSims, setNumSims, sensResults, setSensResults, sensViewGw, setSensViewGw,
chipSolveOptions, setChipSolveOptions, chipSolveSolutions, setChipSolveSolutions, horizonGWs, baselineEv = 0
}) => {
const [lockGwSearch, setLockGwSearch] = useState("");
const [banGwSearch, setBanGwSearch] = useState("");
const { fixtureOverrides, setFixtureOverrides, availableGWs } = useContext(PlayerContext);
const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : "";
const getRelativeEv = (sol) => {
if (baselineEv === undefined || !sol) return "+0.00";
// Prevent 0.0 bug: Use locked baseline if it's the "Last Applied" summary
const base = sol.lockedBaselineEv !== undefined ? sol.lockedBaselineEv : baselineEv;
// Prevent NaN bug: Safely handle if sol is just a raw number by accident
if (typeof sol === "number") {
const diff = sol - base;
return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
}
// Prevent NaN bug: If the API didn't return a full detailed plan (like in Chip Solve)
if (!sol.plan || !Array.isArray(sol.plan) || sol.plan.length === 0) {
const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
const diff = fallbackEv - base;
return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
}
let pathEV = 0;
let hasValidGw = false;
sol.plan.forEach(gwPlan => {
const gw = gwPlan.gw;
if (gw === undefined) return; // Prevents checking p['undefined_Pts']
hasValidGw = true;
const gwChip = gwPlan.chip;
const gwCapMult = gwChip === "tc" ? 3 : 2;
let gwPts = 0;
const getPlayer = (id) => globalPlayers.find(p => String(p.ID) === String(id));
(gwPlan.lineup || []).forEach(id => {
const p = getPlayer(id);
if (p && !p.isBlank) {
const pts = Number(p[`${gw}_Pts`]) || 0;
gwPts += pts * (String(p.ID) === String(gwPlan.captain) ? gwCapMult : 1);
}
});
let ofIdx = 0;
(gwPlan.bench || []).forEach(id => {
const p = getPlayer(id);
if (p && !p.isBlank) {
const pts = Number(p[`${gw}_Pts`]) || 0;
if (gwChip === "bb") {
gwPts += pts;
} else if (p.Pos === "G") {
gwPts += pts * 0.04;
} else {
gwPts += pts * ([0.17, 0.05, 0.02][ofIdx] || 0.02);
ofIdx++;
}
}
});
pathEV += gwPts - (gwPlan.hits || 0) * 4;
});
// Failsafe in case the plan existed but couldn't be parsed properly
if (!hasValidGw || Number.isNaN(pathEV)) {
const fallbackEv = sol.ev !== undefined ? sol.ev : 0;
const diff = fallbackEv - base;
return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
}
const diff = pathEV - base;
return diff >= 0 ? `+${diff.toFixed(2)}` : diff.toFixed(2);
};
return (
<>
<button type="button" onClick={() => setShowAdvancedSettings(true)} className="absolute top-3 right-3 text-slate-500 hover:text-luigi-400 transition-colors z-dropdown" aria-label="Comprehensive settings"><Settings size={20} /></button>
<div className="flex border-b border-slate-800/50 bg-slate-900/50 pr-12">
{["solver", "sensitivity", "chips", "fixtures"].map((tab) => (
<button key={tab} type="button" onClick={() => setSolverTab(tab)} className={`flex-1 py-4 text-xs font-black uppercase tracking-widest transition-colors ${solverTab === tab ? "text-luigi-400 border-b-2 border-luigi-400" : "text-slate-500 hover:text-slate-300"}`}>
{tab === "solver" ? "Solve" : tab === "sensitivity" ? "Sens Anal" : tab === "chips" ? "Chips" : "Fixtures"}
</button>
))}
</div>
<div className="flex flex-col p-5 overflow-y-auto custom-scrollbar min-h-[220px]">
{solverTab === "solver" && (
<div className="flex flex-col gap-4 flex-1">
<h3 className="text-slate-300 font-bold uppercase tracking-wider text-xs mb-1">Quick Settings</h3>
{/* TOOLBAR LAYOUT: Readable sizes, allowed to wrap if screen is small */}
<div className="flex flex-wrap items-start gap-3 w-full">
{/* Decay */}
<div className="flex flex-col gap-1 w-16 sm:w-20 shrink-0">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Decay</label>
<input type="number" step="0.01" value={quickSettings.decay} onChange={(e) => setQuickSettings({ ...quickSettings, decay: e.target.value })} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500" />
</div>
{/* FT Val */}
<div className="flex flex-col gap-1 w-16 sm:w-20 shrink-0">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">FT Val</label>
<input type="number" step="0.1" value={quickSettings.ft_value} onChange={(e) => setQuickSettings({ ...quickSettings, ft_value: Number(e.target.value) })} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500" />
</div>
{/* Iters */}
<div className="flex flex-col gap-1 w-16 sm:w-20 shrink-0">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Iters</label>
<select value={quickSettings.iterations} onChange={e => setQuickSettings({ ...quickSettings, iterations: Number(e.target.value) })} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-slate-200 font-mono text-xs outline-none focus:border-luigi-500 cursor-pointer">
{[1, 2, 3, 4, 5].map(i => <option key={i} value={i}>{i}</option>)}
</select>
</div>
{/* Lock */}
<div className="relative flex-1 min-w-[120px] flex flex-col gap-1">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Lock</label>
<input type="text" placeholder="Search..." value={lockSearch} onChange={(e) => setLockSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-emerald-400 focus:outline-none focus:border-emerald-500" />
{lockSearch && (
<div className="absolute top-full left-0 w-full bg-slate-800 border border-slate-700 rounded mt-1 max-h-32 overflow-y-auto z-popover shadow-xl">
{/* THE FIX: Added (quickSettings.locked || []) */}
{globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(lockSearch)) && !(quickSettings.locked || []).some((l) => l.ID === p.ID)).slice(0, 10).map((p) => (
<div key={`lock-${p.ID}`} onClick={() => { setQuickSettings((prev) => ({ ...prev, locked: [...(prev.locked || []), p] })); setLockSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
{p.Name} <span className="text-slate-500">({p.Team})</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mt-1">
{/* THE FIX: Added (quickSettings.locked || []) */}
{(quickSettings.locked || []).map((p) => (
<span key={`l-${p.ID}`} className="bg-emerald-900/40 text-emerald-400 text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1 border border-emerald-800 max-w-full">
<span className="truncate">{p.Name}</span> <button type="button" onClick={() => setQuickSettings((prev) => ({ ...prev, locked: (prev.locked || []).filter((l) => l.ID !== p.ID) }))} className="hover:text-white shrink-0">✕</button>
</span>
))}
</div>
</div>
{/* Ban */}
<div className="relative flex-1 min-w-[120px] flex flex-col gap-1">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Ban</label>
<input type="text" placeholder="Search..." value={banSearch} onChange={(e) => setBanSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-red-400 focus:outline-none focus:border-red-500" />
{banSearch && (
<div className="absolute top-full left-0 w-full bg-slate-800 border border-slate-700 rounded mt-1 max-h-32 overflow-y-auto z-popover shadow-xl">
{/* THE FIX: Added (quickSettings.banned || []) */}
{globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(banSearch)) && !(quickSettings.banned || []).some((b) => b.ID === p.ID)).slice(0, 10).map((p) => (
<div key={`ban-${p.ID}`} onClick={() => { setQuickSettings((prev) => ({ ...prev, banned: [...(prev.banned || []), p] })); setBanSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
{p.Name} <span className="text-slate-500">({p.Team})</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mt-1">
{/* THE FIX: Added (quickSettings.banned || []) */}
{(quickSettings.banned || []).map((p) => (
<span key={`b-${p.ID}`} className="bg-red-900/40 text-red-400 text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1 border border-red-800 max-w-full">
<span className="truncate">{p.Name}</span> <button type="button" onClick={() => setQuickSettings((prev) => ({ ...prev, banned: (prev.banned || []).filter((b) => b.ID !== p.ID) }))} className="hover:text-white shrink-0">✕</button>
</span>
))}
</div>
</div>
{/* Lock (This GW) */}
<div className="relative flex-1 min-w-[120px] flex flex-col gap-1">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Lock (This GW)</label>
<input type="text" placeholder="Search..." value={lockGwSearch} onChange={(e) => setLockGwSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-emerald-400 focus:outline-none focus:border-emerald-500" />
{lockGwSearch && (
<div className="absolute top-full left-0 w-full bg-slate-800 border border-slate-700 rounded mt-1 max-h-32 overflow-y-auto z-popover shadow-xl">
{globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(lockGwSearch)) && !(quickSettings.lock_this_gw || []).some((l) => l.ID === p.ID)).slice(0, 10).map((p) => (
<div key={`lock-gw-${p.ID}`} onClick={() => { setQuickSettings((prev) => ({ ...prev, lock_this_gw: [...(prev.lock_this_gw || []), p] })); setLockGwSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
{p.Name} <span className="text-slate-500">({p.Team})</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mt-1">
{(quickSettings.lock_this_gw || []).map((p) => (
<span key={`lgw-${p.ID}`} className="bg-emerald-900/40 text-emerald-400 text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1 border border-emerald-800 max-w-full">
<span className="truncate">{p.Name}</span> <button type="button" onClick={() => setQuickSettings((prev) => ({ ...prev, lock_this_gw: (prev.lock_this_gw || []).filter((l) => l.ID !== p.ID) }))} className="hover:text-white shrink-0">✕</button>
</span>
))}
</div>
</div>
{/* Ban (This GW) */}
<div className="relative flex-1 min-w-[120px] flex flex-col gap-1">
<label className="text-[10px] text-slate-500 font-bold uppercase tracking-wider">Ban (This GW)</label>
<input type="text" placeholder="Search..." value={banGwSearch} onChange={(e) => setBanGwSearch(e.target.value)} className="w-full bg-slate-900 border border-slate-700 rounded px-2 py-1.5 text-xs text-red-400 focus:outline-none focus:border-red-500" />
{banGwSearch && (
<div className="absolute top-full left-0 w-full bg-slate-800 border border-slate-700 rounded mt-1 max-h-32 overflow-y-auto z-popover shadow-xl">
{globalPlayers.filter((p) => cleanString(p.Name).includes(cleanString(banGwSearch)) && !(quickSettings.ban_this_gw || []).some((b) => b.ID === p.ID)).slice(0, 10).map((p) => (
<div key={`ban-gw-${p.ID}`} onClick={() => { setQuickSettings((prev) => ({ ...prev, ban_this_gw: [...(prev.ban_this_gw || []), p] })); setBanGwSearch(""); }} className="px-2 py-1.5 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer border-b border-slate-700/50 truncate">
{p.Name} <span className="text-slate-500">({p.Team})</span>
</div>
))}
</div>
)}
<div className="flex flex-wrap gap-1 mt-1">
{(quickSettings.ban_this_gw || []).map((p) => (
<span key={`bgw-${p.ID}`} className="bg-red-900/40 text-red-400 text-[10px] px-1.5 py-0.5 rounded flex items-center gap-1 border border-red-800 max-w-full">
<span className="truncate">{p.Name}</span> <button type="button" onClick={() => setQuickSettings((prev) => ({ ...prev, ban_this_gw: (prev.ban_this_gw || []).filter((b) => b.ID !== p.ID) }))} className="hover:text-white shrink-0">✕</button>
</span>
))}
</div>
</div>
</div>
<button type="button" onClick={runMainSolver} disabled={teamData.some((p) => p.isBlank) || teamData.length === 0 || isSolving} className="mt-auto self-center bg-luigi-500 hover:bg-luigi-400 text-slate-950 font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-xs uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none">
Solve {solveGWLabel ? `(${solveGWLabel})` : ""}
</button>
</div>
)}
{solverTab === "sensitivity" && (
<div className="flex flex-col gap-4 flex-1 overflow-y-auto">
<p className="text-[10px] text-slate-500 leading-relaxed">
Runs <span className="text-slate-300 font-bold">N randomised solves</span> with per-player EV noise.
</p>
<div className="flex items-center gap-3">
<label className="text-xs text-slate-500 font-bold">Simulations</label>
<input type="number" min="2" max="300" value={numSims} onChange={(e) => setNumSims(Math.max(2, Math.min(300, Number(e.target.value))))} className="w-20 bg-slate-900 border border-slate-700 rounded p-2 text-slate-200 font-mono text-sm outline-none focus:border-luigi-500" />
</div>
<button type="button" onClick={runSensAnalysis} disabled={isRunningSens || teamData.some((p) => p.isBlank) || teamData.length === 0} className="self-center bg-cyan-700 hover:bg-cyan-600 text-white font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-sm uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed">
{isRunningSens ? `Running...` : `Run Sims ${solveGWLabel ? `(${solveGWLabel})` : ""}`}
</button>
{sensResults && (
<div className="flex flex-col gap-3 mt-4">
<div className="flex items-center justify-between">
<h4 className="text-slate-300 font-bold text-xs uppercase tracking-wider">{sensResults.valid_runs}/{sensResults.num_sims} valid</h4>
<button onClick={() => { setSensResults(null); setSensViewGw(null); }} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase">Clear</button>
</div>
<div className="flex flex-wrap gap-1.5">
{(sensResults.horizon_gws || []).map((gw) => {
const isChipFree = sensResults.gw_results?.[String(gw)]?.is_chip_free;
const isActive = sensViewGw === gw;
return (
<button key={gw} type="button" onClick={() => setSensViewGw(gw)} className={`w-8 h-8 rounded-full text-[11px] font-black transition-colors ${isActive ? isChipFree ? "bg-purple-500 text-white" : "bg-cyan-500 text-slate-950" : isChipFree ? "bg-purple-900/60 text-purple-300 hover:bg-purple-800/60 ring-1 ring-purple-500/40" : "bg-slate-800 text-slate-400 hover:bg-slate-700"}`}>{gw}</button>
);
})}
</div>
{sensViewGw && sensResults.gw_results?.[String(sensViewGw)] && (() => {
const gd = sensResults.gw_results[String(sensViewGw)];
if (gd.is_chip_free) {
const POS_NAMES = { G: "Goalkeepers", D: "Defenders", M: "Midfielders", F: "Forwards" };
return (
<div className="flex flex-col gap-3">
<div className="text-[10px] text-purple-400 font-bold uppercase tracking-wider">⚡ Wildcard / Free Hit — Squad Selection</div>
{["G", "D", "M", "F"].map((pos) => {
const rows = gd.players?.[pos] || [];
if (!rows.length) return null;
return (
<div key={pos} className="bg-slate-900 rounded-xl p-3">
<h5 className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-2">{POS_NAMES[pos]}</h5>
<div className="flex text-[9px] text-slate-500 font-bold uppercase tracking-wider mb-1 gap-2"><span className="flex-1">Player</span><span className="w-12 text-right">Squad</span><span className="w-12 text-right">Lineup</span></div>
{rows.map((r, i) => (
<div key={i} className="flex items-center py-0.5 text-xs gap-2">
<div className="flex-1 min-w-0"><div className="h-1 rounded-full bg-purple-600/30 mb-0.5" style={{ width: `${r.squad_pct}%` }} /><span className="text-slate-300 truncate block">{r.name}</span></div>
<span className="text-purple-400 font-mono font-bold w-12 text-right shrink-0">{r.squad_pct}%</span><span className="text-cyan-400 font-mono font-bold w-12 text-right shrink-0">{r.lineup_pct}%</span>
</div>
))}
</div>
);
})}
</div>
);
}
return (
<div className="flex flex-col gap-3">
{gd.no_transfer_pct > 0 && <div className="text-[10px] text-slate-500 italic">Hold (no transfer): {gd.no_transfer_pct}% of sims</div>}
{["moves", "buys", "sells"].map((key) => {
const rows = gd[key] || [];
if (!rows.length) return null;
const titles = { moves: "Moves (Out → In)", buys: "Buys", sells: "Sells" };
return (
<div key={key} className="bg-slate-900 rounded-xl p-3">
<h5 className="text-[10px] font-black text-slate-400 uppercase tracking-wider mb-2">{titles[key]}</h5>
{rows.slice(0, 10).map((r, i) => (
<div key={i} className="flex justify-between items-center py-0.5 text-xs gap-2">
<div className="flex-1 min-w-0"><div className="h-1 rounded-full bg-cyan-600/30 mb-0.5" style={{ width: `${r.pct}%` }} /><span className="text-slate-300 truncate block">{r.name}</span></div>
<span className="text-cyan-400 font-mono font-bold shrink-0 ml-2">{r.pct}%</span>
</div>
))}
</div>
);
})}
</div>
);
})()}
</div>
)}
</div>
)}
{solverTab === "chips" && (
<div className="flex flex-col gap-4 flex-1 overflow-y-auto">
<p className="text-[10px] text-slate-500 leading-relaxed">
Select which GWs each chip can be played in, then hit <span className="text-purple-400 font-bold">Chip Solve</span>.
</p>
<div className="grid grid-cols-1 gap-3">
{["wc", "fh", "bb", "tc"].map((key) => {
const cfg = CHIP_CONFIG[key];
const sel = chipSolveOptions[key] || [];
return (
<div key={key} className={`bg-slate-900 border ${cfg.border} rounded-xl p-3`}>
<div className="flex items-center gap-2 mb-1">
<span className={`text-[11px] font-black ${cfg.text} uppercase tracking-wider`}>{cfg.label}</span>
{sel.length > 0 && <span className={`text-[9px] px-1.5 py-0.5 rounded font-bold ${cfg.badge}`}>{sel.length} GW{sel.length > 1 ? "s" : ""}</span>}
</div>
<div className="flex flex-wrap gap-1 mt-2">
{horizonGWs.map((gw) => {
const active = sel.includes(gw);
return (
<button key={gw} type="button" onClick={() => setChipSolveOptions((prev) => ({ ...prev, [key]: active ? prev[key].filter((g) => g !== gw) : [...prev[key], gw] }))} className={`text-[10px] px-1.5 py-0.5 rounded font-bold transition-colors ${active ? cfg.activeBg : cfg.inactiveBg}`}>{gw}</button>
);
})}
</div>
</div>
);
})}
</div>
<button type="button" onClick={runChipSolve} disabled={isChipSolving || teamData.some((p) => p.isBlank) || teamData.length === 0 || Object.values(chipSolveOptions).every((v) => v.length === 0)} className="self-center mt-2 bg-purple-600 hover:bg-purple-500 text-white font-black px-10 py-2.5 rounded-lg shadow-lg transition-all active:scale-95 text-sm uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed">
{isChipSolving ? `Solving...` : "Chip Solve"}
</button>
{chipSolveSolutions.length > 0 && (
<div className="flex flex-col gap-2 mt-4">
<div className="flex items-center justify-between">
<h4 className="text-slate-300 font-bold text-xs uppercase tracking-wider">Best Chip Combos</h4>
<button onClick={() => setChipSolveSolutions([])} className="text-xs text-slate-500 hover:text-red-400 font-bold uppercase">Clear</button>
</div>
{chipSolveSolutions.map((sol, i) => {
const combo = sol.chip_combo || {};
const active = Object.entries(combo)
.filter(([, gws]) => Array.isArray(gws) && gws.length > 0)
.map(([k, gws]) => ({ text: `${k.replace("use_", "").toUpperCase()}${gws[0]}`, gw: gws[0] }))
.sort((a, b) => a.gw - b.gw)
.map(x => x.text);
return (
<div key={i} className="bg-slate-950 border border-purple-500/20 rounded-lg p-2.5 flex items-center justify-between gap-2">
<div className="flex items-center gap-0.5">
{/* 1. Added Rank Number (1., 2., 3...) */}
<span className="text-[11px] font-black text-slate-500 mr-1.5 w-3 text-right">{i + 1}.</span>
<span className="text-[11px] font-bold text-slate-300">{active.length ? active.join(" + ") : "No chips"}</span>
</div>
<div className="flex flex-col items-end shrink-0 leading-tight">
<span className="text-purple-400 font-mono font-bold text-sm">
{sol.objective_score != null ? sol.objective_score.toFixed(2) : sol.ev.toFixed(2)}
</span>
{/* 2. EV is now bigger (text-[10px]) and colored purple to match! */}
<span className="text-[10px] text-purple-400/80 font-mono font-bold mt-0.5">
({getRelativeEv(sol)} ev)
</span>
</div>
</div>
);
})}
</div>
)}
</div>
)}
{solverTab === "fixtures" && (
<FixtureMatrixPanel
globalPlayers={globalPlayers}
fixtureOverrides={fixtureOverrides}
setFixtureOverrides={setFixtureOverrides}
availableGWs={availableGWs}
/>
)}
</div>
</>
);
};