import React, { useState, useEffect, useMemo, useRef, useContext } from 'react'; import { Search, ChevronLeft, ChevronRight, Shield, Download, RotateCcw, Loader2 } from 'lucide-react'; import { getShortName } from '../utils/teams'; import { PlayerContext } from '../PlayerContext'; // --- BASELINE INPUT WITH LIVE AUTO-SAVE --- // --- BASELINE INPUT WITH LIVE AUTO-SAVE & SNAP-PROOF MEMORY --- const BaselineInput = ({ player, handleUpdate }) => { const [val, setVal] = useState(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : ''); useEffect(() => { setVal(player.baseline_xMins != null ? Math.round(player.baseline_xMins) : ''); }, [player.baseline_xMins]); return ( setVal(e.target.value)} onBlur={() => { let num = val === '' ? 0 : parseInt(val, 10); num = Math.max(0, Math.min(90, num)); // CAP FIX: Locks between 0 and 90 setVal(num); handleUpdate(player.ID, 'baseline', null, num); }} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} className="w-12 bg-transparent text-center font-mono text-sm font-bold text-emerald-400 focus:outline-none focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 rounded py-1 hover:bg-slate-800/50 transition-colors cursor-text [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> ); }; // --- DGW CHILD INPUT WITH LIVE AUTO-SAVE --- const SafeChildInput = ({ initialValue, onSave }) => { const [val, setVal] = useState(initialValue); useEffect(() => setVal(initialValue), [initialValue]); return ( setVal(e.target.value)} onBlur={() => { let num = val === '' ? 0 : parseFloat(val); num = Math.max(0, Math.min(90, num)); // CAP FIX setVal(num); onSave(num); }} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} className="w-12 bg-slate-950 text-center font-mono text-xs font-bold text-indigo-400 rounded py-1 outline-none focus:ring-1 ring-indigo-500 border border-slate-800 [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" /> ); }; // --- GW INPUT WITH LIVE AUTO-SAVE & DGW POPOVER --- // --- GW INPUT WITH SAFE FRONTEND SAVING --- // --- GW INPUT WITH SAFE FRONTEND SAVING --- const GwMinsInput = ({ player, gw, handleUpdate }) => { const { effectiveFixtures, sessionEdits, globalXmins } = useContext(PlayerContext); const [showPopover, setShowPopover] = useState(false); const [isFocused, setIsFocused] = useState(false); const popoverRef = useRef(null); const [val, setVal] = useState(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : ''); useEffect(() => { setVal(player[`${gw}_xMins`] != null ? Math.round(player[`${gw}_xMins`]) : ''); }, [player[`${gw}_xMins`]]); useEffect(() => { const handleClickOutside = (event) => { if (popoverRef.current && !popoverRef.current.contains(event.target)) setShowPopover(false); }; if (showPopover) document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [showPopover]); const matches = []; if (player.match_projections) { Object.entries(player.match_projections).forEach(([mId, mData]) => { const override = effectiveFixtures?.[mId]; // THE FIX: Force Number() conversion so strings like "1" don't break the math! if (override && override[gw] > 0) matches.push({ ...mData, id: mId, prob: Number(override[gw]) }); else if (!override && String(mData.default_gw) === String(gw)) matches.push({ ...mData, id: mId, prob: 1.0 }); }); } const hasMultiple = matches.length > 1 || (matches.length === 1 && Math.abs(matches[0].prob - 1.0) > 0.001); const isBlank = matches.length === 0; const TEAM_SHORTS = { 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE", 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL", 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW", 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL" }; if (isBlank) return -; const hoverFixtureText = matches.map(m => `${TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id} ${m.is_home ? '(H)' : '(A)'}`).join(" & "); const handleParentSave = (newVal) => { let numVal = newVal === '' ? 0 : parseFloat(newVal); numVal = Math.max(0, Math.min(90, numVal)); // CAP FIX const currentAvg = Math.round(player[`${gw}_xMins`] || 0); if (numVal === currentAvg) { setVal(numVal); return; } setVal(numVal); // Snaps the UI instantly if (hasMultiple) { const edits = {}; matches.forEach(m => { edits[m.id] = numVal; }); handleUpdate(player.ID, 'batch', edits, null); } else { handleUpdate(player.ID, 'single', gw, numVal); } }; return (
{isFocused && !hasMultiple && !isBlank && (
{hoverFixtureText}
)} hasMultiple && setShowPopover(true)} onFocus={() => !hasMultiple && setIsFocused(true)} onChange={(e) => !hasMultiple && setVal(e.target.value)} onBlur={(e) => { if (!hasMultiple) { setIsFocused(false); // Hides tooltip when you click away handleParentSave(e.target.value); } }} onKeyDown={(e) => e.key === 'Enter' && e.target.blur()} className={`w-12 bg-transparent text-center font-mono text-sm font-bold rounded py-1 outline-none transition-colors ${hasMultiple ? 'text-indigo-300 hover:bg-slate-800/50 cursor-pointer focus:ring-1 ring-indigo-500' : 'text-emerald-400 focus:bg-slate-950/80 focus:ring-1 ring-emerald-500 hover:bg-slate-800/50'} [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`} /> {showPopover && hasMultiple && (
Edit Match Splits
{matches.map(m => { const oppName = TEAM_SHORTS[m.opponent_team_id] || m.opponent_team_id || "OPP"; const fixLabel = m.is_home ? `${oppName} (H)` : `${oppName} (A)`; const globalMatchMins = globalXmins?.[player.ID]?.[m.id]; const sessionVal = sessionEdits?.[player.ID]?.[`${m.id}_xMins`]; const currentMins = Math.round(sessionVal !== undefined ? Number(sessionVal) : (globalMatchMins !== undefined ? Number(globalMatchMins) : m.xMins)); return (
{fixLabel} ({Math.round(m.prob * 100)}%) handleUpdate(player.ID, 'single', m.id, newVal)} />
); })}
)}
); }; export default function ProjectionsTable() { const { globalPlayers: players, setGlobalPlayers, isLoadingDB, projSearchTerm: searchTerm, setProjSearchTerm: setSearchTerm, sessionEdits, setSessionEdits, manualOverrides, effectiveFixtures, setOriginalPlayers, globalXmins } = useContext(PlayerContext); const [sortConfig, setSortConfig] = useState({ key: 'Total Points', direction: 'desc' }); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = typeof window !== 'undefined' && window.innerWidth < 768 ? 15 : 50; const tableContainerRef = useRef(null); useEffect(() => { if (tableContainerRef.current) { tableContainerRef.current.scrollTo({ top: 0, behavior: 'smooth' }); } }, [currentPage]); const [isAdmin, setIsAdmin] = useState(false); const [adminPassword, setAdminPassword] = useState(''); const [showAdminLogin, setShowAdminLogin] = useState(false); const [clickCount, setClickCount] = useState(0); const clickTimeoutRef = useRef(null); const handleSecretClick = () => { setClickCount((prev) => { const newCount = prev + 1; if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; } return newCount; }); if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current); clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000); }; const gameweeks = useMemo(() => { if (!players || players.length === 0) return []; const gwSet = new Set(); Object.keys(players[0]).forEach(k => { if (/^\d+_Pts$/.test(k)) { const num = parseInt(k.split('_')[0], 10); if (num >= 1 && num <= 38) gwSet.add(num); } }); return Array.from(gwSet).sort((a, b) => a - b); }, [players]); const getDynamicTotal = (p) => gameweeks.reduce((sum, gw) => sum + (Number(p[`${gw}_Pts`]) || 0), 0); const getDynamicAvg = (p) => gameweeks.length > 0 ? getDynamicTotal(p) / gameweeks.length : 0; const handleUpdate = async (playerId, type, gw, valueStr) => { const value = type === 'baseline' ? parseInt(valueStr, 10) || 0 : parseFloat(valueStr) || 0; // 1. Grab the active baseline from memory so Python doesn't forget it! const activeBaseline = sessionEdits[playerId]?.baseline_xMins; // 2. Prevent Python Crash: Separate Match IDs (13_vs_1) from real Gameweeks (34) const realGwEdits = {}; if (type === 'batch') { Object.keys(gw).forEach(k => { realGwEdits[k] = gw[k]; }); } else if (type === 'single') { realGwEdits[gw] = value; } // 3. Update local React memory instantly (handles DGW splits perfectly) setSessionEdits(prev => { const next = { ...prev }; if (!next[playerId]) next[playerId] = {}; if (type === 'baseline') { next[playerId]['baseline_xMins'] = value; } else if (type === 'batch') { Object.keys(gw).forEach(k => { next[playerId][`${k}_xMins`] = gw[k]; }); } else { next[playerId][`${gw}_xMins`] = value; } const token = localStorage.getItem('fpl_token'); if (token) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ saved_edits: { ...next, _solver_overrides: manualOverrides } }) }); } return next; }); // 4. If this is a DGW match split ('13_vs_1'), STOP HERE. Don't crash Python! try { const payload = { player_id: playerId, is_admin: isAdmin, admin_password: adminPassword, gw_edits: realGwEdits }; // 5. Prevent Reset Bug: ALWAYS send the baseline to Python! if (type === 'baseline') { payload.baseline_edit = value; } else if (activeBaseline !== undefined) { payload.baseline_edit = activeBaseline; } const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/player/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend recalculation failed'); } const updatedRow = await res.json(); // 6. Merge Python's exact decayed math into the table if (setGlobalPlayers) { setGlobalPlayers(prev => prev.map(p => { const newBaseline = type === 'baseline' ? (valueStr === '' ? null : value) : (activeBaseline !== undefined ? activeBaseline : p.baseline_xMins); if (p.ID === playerId) { // THE FIX: Protect the original match dictionary so Python doesn't wipe out the DGW tags! const pristineMatches = p.match_projections; return { ...p, ...updatedRow, match_projections: pristineMatches, baseline_xMins: newBaseline }; } return p; })); } // 7. Lock Python's curve into memory (from your old working file) if (type === 'baseline') { setSessionEdits(prev => { const next = { ...prev }; gameweeks.forEach(g => { next[playerId][`${g}_xMins`] = updatedRow[`${g}_xMins`]; next[playerId][`${g}_Pts`] = updatedRow[`${g}_Pts`]; }); return next; }); } } catch (err) { console.error("Recalculation error:", err); } }; const resetPlayer = async (playerId) => { try { const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections'); const freshData = await res.json(); const cleanPlayer = freshData.find(p => p.ID === playerId); if (cleanPlayer && setGlobalPlayers) { // THE FLICKER FIX: Apply the UI math interceptor instantly before merging the reset player! if (cleanPlayer.match_projections) { gameweeks.forEach(g => { cleanPlayer[`${g}_Pts`] = 0; cleanPlayer[`${g}_xMins`] = 0; cleanPlayer[`${g}_probSum`] = 0; }); Object.entries(cleanPlayer.match_projections).forEach(([mId, mData]) => { const pts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0); const mins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0); const override = effectiveFixtures?.[mId]; if (override) { Object.entries(override).forEach(([gwStr, prob]) => { if (prob > 0) { cleanPlayer[`${gwStr}_Pts`] = (cleanPlayer[`${gwStr}_Pts`] || 0) + (pts * prob); cleanPlayer[`${gwStr}_xMins`] = (cleanPlayer[`${gwStr}_xMins`] || 0) + (mins * prob); cleanPlayer[`${gwStr}_probSum`] = (cleanPlayer[`${gwStr}_probSum`] || 0) + prob; } }); } else { const defGw = mData.default_gw; if (defGw) { cleanPlayer[`${defGw}_Pts`] = (cleanPlayer[`${defGw}_Pts`] || 0) + pts; cleanPlayer[`${defGw}_xMins`] = (cleanPlayer[`${defGw}_xMins`] || 0) + mins; cleanPlayer[`${defGw}_probSum`] = (cleanPlayer[`${defGw}_probSum`] || 0) + 1.0; } } }); gameweeks.forEach(g => { if (cleanPlayer[`${g}_probSum`] > 0) { cleanPlayer[`${g}_xMins`] = cleanPlayer[`${g}_xMins`] / cleanPlayer[`${g}_probSum`]; } }); } setGlobalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p)); if (setOriginalPlayers) { setOriginalPlayers(prev => prev.map(p => p.ID === playerId ? cleanPlayer : p)); } setSessionEdits(prev => { const newEdits = { ...prev }; delete newEdits[playerId]; const token = localStorage.getItem('fpl_token'); if (token) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ saved_edits: { ...newEdits, _solver_overrides: manualOverrides } }) }); } return newEdits; }); } } catch (e) { console.error("Failed to reset player", e); } }; const resetAll = async () => { try { const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/projections'); const freshData = await res.json(); if (setGlobalPlayers) setGlobalPlayers(freshData); setSessionEdits(prev => { const token = localStorage.getItem('fpl_token'); if (token) { fetch('https://anayshukla-fpl-solver.hf.space/api/auth/save_session', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ saved_edits: { _solver_overrides: manualOverrides } }) }); } return {}; }); } catch (e) { console.error("Failed to reset all", e); } }; const downloadCSV = () => { if (!players || players.length === 0) return; const headers = ["Pos", "ID", "Name", "BV", "SV", "Team"]; gameweeks.forEach(gw => { headers.push(`${gw}_xMins`, `${gw}_Pts`); }); headers.push("Total Points", "Average Points"); let csvContent = "data:text/csv;charset=utf-8,"; csvContent += headers.join(",") + "\n"; const escapeCsv = (str) => { if (str == null) return ""; const s = String(str); return s.includes(",") ? `"${s}"` : s; }; sortedAndFilteredData.forEach(p => { const row = [ p.Pos, p.ID, escapeCsv(p.Name), p.BV, p.SV !== undefined ? p.SV : p.BV, escapeCsv(p.Team) ]; gameweeks.forEach(gw => { const mins = Number(p[`${gw}_xMins`]) || 0; const pts = Number(p[`${gw}_Pts`]) || 0; row.push(Math.round(mins)); row.push(pts.toFixed(2)); }); row.push(getDynamicTotal(p).toFixed(2)); row.push(getDynamicAvg(p).toFixed(2)); csvContent += row.join(",") + "\n"; }); const encodedUri = encodeURI(csvContent); const link = document.createElement("a"); link.setAttribute("href", encodedUri); link.setAttribute("download", `luigis_mansion.csv`); document.body.appendChild(link); link.click(); document.body.removeChild(link); }; const handleSort = (key) => { let direction = 'desc'; if (sortConfig.key === key && sortConfig.direction === 'desc') direction = 'asc'; setSortConfig({ key, direction }); }; const sortedAndFilteredData = useMemo(() => { if (!players) return []; // Add the special character normalizer const cleanString = (str) => str ? str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase() : ""; const cleanSearch = cleanString(searchTerm); let filtered = searchTerm ? players.filter(p => cleanString(p.Name).includes(cleanSearch)) : [...players]; return filtered.sort((a, b) => { let valA = sortConfig.key === 'Total Points' ? getDynamicTotal(a) : (sortConfig.key === 'Average Points' ? getDynamicAvg(a) : a[sortConfig.key]); let valB = sortConfig.key === 'Total Points' ? getDynamicTotal(b) : (sortConfig.key === 'Average Points' ? getDynamicAvg(b) : b[sortConfig.key]); if (sortConfig.key === 'Team') { valA = getShortName(valA); valB = getShortName(valB); } if (valA < valB) return sortConfig.direction === 'asc' ? -1 : 1; if (valA > valB) return sortConfig.direction === 'asc' ? 1 : -1; return 0; }); }, [players, sortConfig, searchTerm, gameweeks]); useEffect(() => setCurrentPage(1), [searchTerm, sortConfig]); const totalPages = Math.ceil(sortedAndFilteredData.length / itemsPerPage); const paginatedData = sortedAndFilteredData.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage); const getMinsColor = (mins) => `rgba(52, 211, 153, ${Math.min(mins / 90, 1) * 0.4})`; const getPtsColor = (pts) => pts <= 0 ? 'transparent' : `rgba(16, 185, 129, ${Math.min(pts / 10, 1) * 0.6})`; if (isLoadingDB) return
; const displayedPlayers = useMemo(() => { return paginatedData.map(p => { if (!p.match_projections) return p; const cloned = { ...p }; gameweeks.forEach(g => { cloned[`${g}_Pts`] = 0; cloned[`${g}_xMins`] = 0; cloned[`${g}_probSum`] = 0; }); const manualBaseline = sessionEdits[p.ID]?.baseline_xMins; Object.entries(p.match_projections).forEach(([mId, mData]) => { const override = effectiveFixtures?.[mId]; let manualMins = sessionEdits[p.ID]?.[`${mId}_xMins`]; const globalMatchMins = globalXmins?.[p.ID]?.[mId]; if (manualMins === undefined) { if (globalMatchMins !== undefined) { manualMins = globalMatchMins; } else { let activeGw = override ? Object.keys(override).find(g => override[g] > 0) : mData.default_gw; if (activeGw) manualMins = sessionEdits[p.ID]?.[`${activeGw}_xMins`] ?? globalXmins?.[p.ID]?.[activeGw]; } } // Safely get the unedited minutes from the backend const origMins = mData.xMins !== undefined ? mData.xMins : (mData.mins || 0); let activeMins = origMins; // THE DECAY FIX: Use a ratio to preserve the backend curve instead of flattening it if (manualMins !== undefined) { activeMins = Number(manualMins); } else if (manualBaseline !== undefined) { const origBase = p.baseline_xMins || 90; const ratio = origBase > 0 ? (Number(manualBaseline) / origBase) : 1.0; activeMins = Math.min((origMins * ratio), 90); } const scaling = (activeMins > 0 && origMins > 0) ? (activeMins / origMins) : (activeMins === 0 ? 0 : 1); const basePts = mData.Pts !== undefined ? mData.Pts : (mData.points || 0); const aPts = basePts * scaling; if (override) { Object.entries(override).forEach(([gwStr, prob]) => { if (prob > 0) { cloned[`${gwStr}_Pts`] = (cloned[`${gwStr}_Pts`] || 0) + (aPts * prob); cloned[`${gwStr}_xMins`] = (cloned[`${gwStr}_xMins`] || 0) + (activeMins * prob); cloned[`${gwStr}_probSum`] = (cloned[`${gwStr}_probSum`] || 0) + prob; } }); } else { const defGw = mData.default_gw; if (defGw) { cloned[`${defGw}_Pts`] = (cloned[`${defGw}_Pts`] || 0) + aPts; cloned[`${defGw}_xMins`] = (cloned[`${defGw}_xMins`] || 0) + activeMins; cloned[`${defGw}_probSum`] = (cloned[`${defGw}_probSum`] || 0) + 1.0; } } }); gameweeks.forEach(g => { if (cloned[`${g}_probSum`] > 0) { cloned[`${g}_xMins`] = cloned[`${g}_xMins`] / cloned[`${g}_probSum`]; } }); return cloned; }); }, [paginatedData, sessionEdits, effectiveFixtures, gameweeks]); return (
setSearchTerm(e.target.value)} className="w-full bg-slate-950/80 border border-slate-700 rounded-lg py-2 pl-10 pr-10 text-sm text-slate-200 focus:outline-none focus:border-luigi-400" /> {isAdmin && }
{showAdminLogin && !isAdmin && (
setAdminPassword(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && setIsAdmin(true)} className="bg-slate-950 border border-slate-700 rounded py-1.5 px-3 text-sm w-32 focus:outline-none focus:border-luigi-400 text-slate-200" />
)}
{Object.keys(sessionEdits).length > 0 && ( )}
{gameweeks.map(gw => ( ))} {displayedPlayers.map((player) => ( {gameweeks.map(gw => ( ))} ))}
handleSort('Pos')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Pos handleSort('Name')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Name handleSort('Team')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Team handleSort('BV')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Cost handleSort('baseline_xMins')} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-900/50 cursor-pointer">
Baseline
xMins
GW{gw}
handleSort(`${gw}_xMins`)}>xMins handleSort(`${gw}_Pts`)}>xPts
handleSort('Total Points')} className="px-3 py-4 text-right cursor-pointer hover:text-slate-200 text-luigi-400 border-l border-slate-800/50 bg-slate-950 whitespace-nowrap">Total Reset
{player.Pos} {player.Name} {getShortName(player.Team)} {player.BV}
{/* TAG FIX: Tiny, padded, and pointer-events-none so it never blocks clicks */} {player[`${gw}_probSum`] > 1.01 && ( DGW )} {player[`${gw}_probSum`] < 0.99 && player[`${gw}_probSum`] > 0.01 && ( % )}
{Number(player[`${gw}_Pts`]).toFixed(2)}
{getDynamicTotal(player).toFixed(2)} {sessionEdits[player.ID] && ( )}
{totalPages > 1 && (
Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)} of {sortedAndFilteredData.length} players
)}
); }