Spaces:
Running
Running
| 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 ( | |
| <input | |
| type="number" | |
| value={val} | |
| onChange={(e) => 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 ( | |
| <input | |
| type="number" | |
| value={val} | |
| onChange={(e) => 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 <span className="text-[10px] font-bold text-slate-600">-</span>; | |
| 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 ( | |
| <div className="relative flex justify-center w-full pl-2" ref={popoverRef} title={hoverFixtureText}> | |
| {isFocused && !hasMultiple && !isBlank && ( | |
| <div className="absolute bottom-full mb-1 left-1/2 -translate-x-1/2 bg-slate-800 text-indigo-300 text-[10px] font-bold px-2 py-0.5 rounded shadow-xl border border-indigo-500/30 whitespace-nowrap z-tooltip pointer-events-none animate-in fade-in zoom-in-95"> | |
| {hoverFixtureText} | |
| </div> | |
| )} | |
| <input | |
| type="number" | |
| title={hoverFixtureText} | |
| value={val} | |
| onClick={() => 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 && ( | |
| <div className="absolute top-0 right-full mr-2 w-48 bg-slate-900 border border-indigo-500/50 rounded-lg shadow-2xl z-popover flex flex-col overflow-hidden animate-in fade-in zoom-in-95"> | |
| <div className="bg-indigo-950/50 text-[9px] font-bold text-indigo-300 uppercase tracking-widest p-2 border-b border-indigo-500/30 flex justify-between items-center"> | |
| <span>Edit Match Splits</span> | |
| <button onClick={(e) => { e.stopPropagation(); setShowPopover(false); }} className="text-indigo-400 hover:text-white text-xs">✕</button> | |
| </div> | |
| <div className="p-2 flex flex-col gap-2"> | |
| {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 ( | |
| <div key={m.id} className="flex items-center justify-between gap-2"> | |
| <span className="text-[10px] font-bold text-slate-300 truncate flex-1">{fixLabel} <span className="opacity-50">({Math.round(m.prob * 100)}%)</span></span> | |
| <SafeChildInput | |
| initialValue={currentMins} | |
| onSave={(newVal) => handleUpdate(player.ID, 'single', m.id, newVal)} | |
| /> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| 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 <div className="flex items-center justify-center h-64"><Loader2 size={32} className="animate-spin text-emerald-500" /></div>; | |
| 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 ( | |
| <div className="space-y-4 w-full"> | |
| <div className="flex flex-col md:flex-row justify-between items-center gap-4 bg-slate-900/40 p-4 rounded-xl border border-slate-800 backdrop-blur-sm shadow-sm"> | |
| <div className="flex gap-4 items-center"> | |
| <div className="relative w-72 flex items-center"> | |
| <div className="absolute left-0 w-10 h-full flex items-center justify-center cursor-pointer z-dropdown" onClick={handleSecretClick}> | |
| <Search className="text-slate-500 pointer-events-none" size={18} /> | |
| </div> | |
| <input type="text" placeholder="Search players..." value={searchTerm} onChange={(e) => 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 && <Shield size={14} className="absolute right-3 text-luigi-500" title="Admin Mode Active" />} | |
| </div> | |
| {showAdminLogin && !isAdmin && ( | |
| <div className="flex gap-2 animate-in fade-in slide-in-from-left-4 duration-300"> | |
| <input type="password" placeholder="Admin Pass" value={adminPassword} onChange={(e) => 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" /> | |
| <button onClick={() => setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-sm text-white transition-colors">Login</button> | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex gap-3"> | |
| {Object.keys(sessionEdits).length > 0 && ( | |
| <button onClick={resetAll} className="flex items-center gap-2 px-3 py-2 text-sm bg-red-900/30 text-red-400 border border-red-900/50 rounded-lg hover:bg-red-900/50 transition-colors"><RotateCcw size={16} /> Reset to Default</button> | |
| )} | |
| <button onClick={downloadCSV} className="flex items-center gap-2 px-4 py-2 text-sm bg-luigi-500 text-slate-950 font-bold rounded-lg hover:bg-luigi-400 transition-colors shadow-lg shadow-luigi-500/20"><Download size={16} /> Export CSV</button> | |
| </div> | |
| </div> | |
| <div ref={tableContainerRef} className="rounded-xl border border-slate-800 bg-slate-900/40 backdrop-blur-sm shadow-xl max-h-[70vh] overflow-y-auto overflow-x-auto [&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar]:h-2 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-700/50 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-slate-600/80"> | |
| <table className="w-full text-sm text-left text-slate-300 relative"> | |
| <thead className="text-[11px] text-slate-400 uppercase bg-slate-950 border-b border-slate-800 sticky top-0 z-sticky shadow-sm"> | |
| <tr> | |
| <th onClick={() => handleSort('Pos')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Pos</th> | |
| <th onClick={() => handleSort('Name')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Name</th> | |
| <th onClick={() => handleSort('Team')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 whitespace-nowrap">Team</th> | |
| <th onClick={() => handleSort('BV')} className="px-3 py-4 cursor-pointer hover:text-slate-200 bg-slate-950 text-center whitespace-nowrap">Cost</th> | |
| <th onClick={() => handleSort('baseline_xMins')} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-900/50 cursor-pointer"> | |
| <div className="flex flex-col items-center gap-1"><span className="text-emerald-400 font-bold tracking-wider">Baseline</span><div className="flex w-full text-[10px] text-slate-500 justify-center px-1">xMins</div></div> | |
| </th> | |
| {gameweeks.map(gw => ( | |
| <th key={gw} className="px-2 py-3 text-center border-l border-slate-800/50 bg-slate-950"> | |
| <div className="flex flex-col items-center gap-1"> | |
| <span className="text-luigi-400 font-bold tracking-wider">GW{gw}</span> | |
| <div className="flex w-full text-[10px] text-slate-500 justify-around px-1 gap-2"> | |
| <span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_xMins`)}>xMins</span> | |
| <span className="cursor-pointer hover:text-slate-300" onClick={() => handleSort(`${gw}_Pts`)}>xPts</span> | |
| </div> | |
| </div> | |
| </th> | |
| ))} | |
| <th onClick={() => 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</th> | |
| <th className="px-3 py-4 text-center bg-slate-950 border-l border-slate-800/50 whitespace-nowrap">Reset</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-slate-800/50"> | |
| {displayedPlayers.map((player) => ( | |
| <tr key={player.ID} className={`transition-colors group ${sessionEdits[player.ID] ? 'bg-luigi-900/10' : 'hover:bg-slate-800/30'}`}> | |
| <td className="px-3 py-2 font-medium text-slate-500 text-center whitespace-nowrap">{player.Pos}</td> | |
| <td className="px-3 py-2 font-bold text-slate-100 truncate max-w-[160px]">{player.Name}</td> | |
| <td className="px-3 py-2 text-slate-400 font-bold text-center">{getShortName(player.Team)}</td> | |
| <td className="px-3 py-2 text-center whitespace-nowrap">{player.BV}</td> | |
| <td className="p-0 border-l border-slate-800/30 bg-slate-900/30"> | |
| <div className="w-full h-full p-1.5 flex items-center justify-center"> | |
| <BaselineInput | |
| player={player} | |
| handleUpdate={handleUpdate} | |
| /> | |
| </div> | |
| </td> | |
| {gameweeks.map(gw => ( | |
| <td key={gw} className="p-0 border-l border-slate-800/30"> | |
| <div className="flex h-full items-stretch"> | |
| <div className="relative w-1/2 p-2 border-r border-slate-800/20 flex items-center justify-center" style={{ backgroundColor: getMinsColor(player[`${gw}_xMins`]) }}> | |
| <GwMinsInput | |
| player={player} | |
| gw={gw} | |
| handleUpdate={handleUpdate} | |
| /> | |
| {/* TAG FIX: Tiny, padded, and pointer-events-none so it never blocks clicks */} | |
| {player[`${gw}_probSum`] > 1.01 && ( | |
| <span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-indigo-500/90 text-white rounded-bl font-black tracking-tighter pointer-events-none" title={`DGW`}> | |
| DGW | |
| </span> | |
| )} | |
| {player[`${gw}_probSum`] < 0.99 && player[`${gw}_probSum`] > 0.01 && ( | |
| <span className="absolute top-0 right-0 text-[7px] leading-none py-[2px] px-1 bg-orange-500/90 text-white rounded-bl font-black pointer-events-none" title="Odds of the fixture happening"> | |
| % | |
| </span> | |
| )} | |
| </div> | |
| <div className="w-1/2 p-2 text-center font-mono text-sm font-bold flex items-center justify-center" style={{ backgroundColor: getPtsColor(player[`${gw}_Pts`]) }}> | |
| <span className="drop-shadow-md">{Number(player[`${gw}_Pts`]).toFixed(2)}</span> | |
| </div> | |
| </div> | |
| </td> | |
| ))} | |
| <td className="px-3 py-2 text-right font-bold text-luigi-400 font-mono border-l border-slate-800/30 bg-slate-900/20 group-hover:bg-transparent">{getDynamicTotal(player).toFixed(2)}</td> | |
| <td className="px-2 py-2 text-center border-l border-slate-800/30"> | |
| {sessionEdits[player.ID] && ( | |
| <button onClick={() => resetPlayer(player.ID)} className="p-1 text-slate-500 hover:text-red-400 transition-colors" title="Reset Player"> | |
| <RotateCcw size={16} /> | |
| </button> | |
| )} | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| {totalPages > 1 && ( | |
| <div className="flex items-center justify-between px-4 py-3 bg-slate-900/40 border border-slate-800 rounded-xl mt-2"> | |
| <span className="text-sm text-slate-400"> | |
| Showing <span className="font-bold text-slate-200">{(currentPage - 1) * itemsPerPage + 1}</span> to <span className="font-bold text-slate-200">{Math.min(currentPage * itemsPerPage, sortedAndFilteredData.length)}</span> of <span className="font-bold text-slate-200">{sortedAndFilteredData.length}</span> players | |
| </span> | |
| <div className="flex gap-2"> | |
| <button onClick={() => setCurrentPage(p => Math.max(1, p - 1))} disabled={currentPage === 1} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors"> | |
| <ChevronLeft size={20} /> | |
| </button> | |
| <button onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} disabled={currentPage === totalPages} className="p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 disabled:opacity-50 transition-colors"> | |
| <ChevronRight size={20} /> | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } |