Spaces:
Running
Running
| import React, { useMemo, useState, useRef, useContext } from "react"; | |
| import { Plus, Trash2, Zap, Search, X, Database } from "lucide-react"; | |
| import { PlayerContext } from '../PlayerContext'; | |
| export const FixtureMatrixPanel = ({ | |
| globalPlayers, | |
| fixtureOverrides, | |
| setFixtureOverrides, | |
| availableGWs | |
| }) => { | |
| const { globalFixtures = {}, effectiveFixtures = {} } = useContext(PlayerContext); | |
| const [search, setSearch] = useState(""); | |
| // --- ADMIN BACKDOOR STATE --- | |
| const [isAdmin, setIsAdmin] = useState(false); | |
| const [adminPassword, setAdminPassword] = useState(''); | |
| const [showAdminLogin, setShowAdminLogin] = useState(false); | |
| const [clickCount, setClickCount] = useState(0); | |
| const clickTimeoutRef = useRef(null); | |
| const handleSecretClick = () => { | |
| setClickCount((prev) => { | |
| const newCount = prev + 1; | |
| if (newCount === 5) { setShowAdminLogin(!showAdminLogin); return 0; } | |
| return newCount; | |
| }); | |
| if (clickTimeoutRef.current) clearTimeout(clickTimeoutRef.current); | |
| clickTimeoutRef.current = setTimeout(() => setClickCount(0), 1000); | |
| }; | |
| const handlePublishGlobal = async () => { | |
| if (!window.confirm("WARNING: This will overwrite the live FPL database for ALL users. Proceed?")) return; | |
| try { | |
| const res = await fetch('https://anayshukla-fpl-solver.hf.space/api/fixtures/update', { | |
| method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| is_admin: isAdmin, admin_password: adminPassword, | |
| overrides: { ...globalFixtures, ...fixtureOverrides } // Merges admin edits with existing globals | |
| }) | |
| }); | |
| if (!res.ok) { if (res.status === 401) { alert("Invalid Admin Password!"); setIsAdmin(false); } throw new Error('Backend publish failed'); } | |
| alert("Success! Global fixtures updated. All users will see these on refresh."); | |
| } catch (err) { console.error("Publish error:", err); } | |
| }; | |
| // 1. Extract all matches | |
| const allMatches = useMemo(() => { | |
| const TEAM_SHORTS = { | |
| 1: "ARS", 2: "AVL", 3: "BUR", 4: "BOU", 5: "BRE", | |
| 6: "BHA", 7: "CHE", 8: "CRY", 9: "EVE", 10: "FUL", | |
| 11: "LEE", 12: "LIV", 13: "MCI", 14: "MUN", 15: "NEW", | |
| 16: "NFO", 17: "SUN", 18: "TOT", 19: "WHU", 20: "WOL" | |
| }; | |
| const matchMap = new Map(); | |
| globalPlayers.forEach(p => { | |
| if (p.match_projections) { | |
| Object.entries(p.match_projections).forEach(([matchId, data]) => { | |
| if (!matchMap.has(matchId)) { | |
| const [homeId, awayId] = matchId.split("_vs_"); | |
| const hName = TEAM_SHORTS[homeId] || homeId; | |
| const aName = TEAM_SHORTS[awayId] || awayId; | |
| matchMap.set(matchId, { | |
| id: matchId, | |
| homeTeam: hName, | |
| awayTeam: aName, | |
| defaultGw: data.default_gw, | |
| searchString: `${hName} ${aName}`.toLowerCase() | |
| }); | |
| } | |
| }); | |
| } | |
| }); | |
| return Array.from(matchMap.values()).sort((a, b) => a.defaultGw - b.defaultGw); | |
| }, [globalPlayers]); | |
| const activeSplits = allMatches.filter(m => effectiveFixtures[m.id]); | |
| const searchResults = search | |
| ? allMatches.filter(m => !effectiveFixtures[m.id] && m.searchString.includes(search.toLowerCase())).slice(0, 10) | |
| : []; | |
| // --- HANDLERS --- | |
| const handleAddOverride = (match) => { | |
| // THE FIX 4b: If they search a hidden global fixture, load its true splits! Otherwise default to 100%. | |
| const initialSplit = globalFixtures[match.id] ? { ...globalFixtures[match.id] } : { [match.defaultGw]: 1.0 }; | |
| setFixtureOverrides(prev => ({ ...prev, [match.id]: initialSplit })); | |
| setSearch(""); | |
| }; | |
| // 1. Create a "Blank" Split Row | |
| const handleAddSplitGw = (matchId) => { | |
| setFixtureOverrides(prev => { | |
| const next = { ...prev }; | |
| const tempId = `unselected_${Date.now()}`; | |
| next[matchId] = { ...next[matchId], [tempId]: 0.0 }; | |
| return next; | |
| }); | |
| }; | |
| // Convert the "Blank" row into a real GW when user selects from dropdown | |
| const handleChangeSplitGw = (matchId, oldGw, newGw) => { | |
| setFixtureOverrides(prev => { | |
| const next = { ...prev }; | |
| const matchOverrides = { ...next[matchId] }; | |
| const prob = matchOverrides[oldGw]; | |
| delete matchOverrides[oldGw]; | |
| matchOverrides[newGw] = prob; | |
| next[matchId] = matchOverrides; | |
| return next; | |
| }); | |
| }; | |
| // 2. The Auto-Balancer Engine (Always enforces 100% sum) | |
| const handleUpdateSplit = (matchId, gw, newProbRaw) => { | |
| setFixtureOverrides(prev => { | |
| const next = { ...prev }; | |
| const matchOverrides = { ...next[matchId] }; | |
| let newProb = Math.min(Math.max(parseFloat(newProbRaw), 0), 1); | |
| const oldProb = matchOverrides[gw] || 0; | |
| let diff = newProb - oldProb; | |
| // Find all OTHER gameweeks that are already fully configured (ignoring blanks) | |
| const otherGws = Object.keys(matchOverrides).filter(k => k !== String(gw) && !k.startsWith('unselected')); | |
| if (otherGws.length > 0 && diff !== 0) { | |
| if (otherGws.length === 1) { | |
| // If 2 total GWs, modifying one perfectly scales the other | |
| let otherProb = matchOverrides[otherGws[0]] - diff; | |
| otherProb = Math.min(Math.max(otherProb, 0), 1); | |
| matchOverrides[otherGws[0]] = otherProb; | |
| newProb = 1 - otherProb; | |
| } else { | |
| // If 3+ GWs, distribute the remainder proportionally | |
| let sumOthers = otherGws.reduce((acc, key) => acc + matchOverrides[key], 0); | |
| if (sumOthers === 0) { | |
| matchOverrides[otherGws[0]] = 1 - newProb; | |
| } else { | |
| const targetOthersSum = 1 - newProb; | |
| otherGws.forEach(k => { | |
| matchOverrides[k] = (matchOverrides[k] / sumOthers) * targetOthersSum; | |
| }); | |
| } | |
| } | |
| } | |
| matchOverrides[gw] = newProb; | |
| next[matchId] = matchOverrides; | |
| return next; | |
| }); | |
| }; | |
| // Deleting a row gives its probability to the remaining rows | |
| const handleRemoveSplit = (matchId, gw) => { | |
| setFixtureOverrides(prev => { | |
| const next = { ...prev }; | |
| const matchOverrides = { ...next[matchId] }; | |
| const deletedProb = matchOverrides[gw] || 0; | |
| delete matchOverrides[gw]; | |
| const remaining = Object.keys(matchOverrides).filter(k => !k.startsWith('unselected')); | |
| if (remaining.length > 0 && deletedProb > 0) { | |
| if (remaining.length === 1) { | |
| matchOverrides[remaining[0]] += deletedProb; | |
| } else { | |
| let sumRem = remaining.reduce((a, k) => a + matchOverrides[k], 0); | |
| if(sumRem === 0) { | |
| matchOverrides[remaining[0]] = 1.0; | |
| } else { | |
| remaining.forEach(k => { | |
| matchOverrides[k] += (matchOverrides[k] / sumRem) * deletedProb; | |
| }); | |
| } | |
| } | |
| } | |
| if (Object.keys(matchOverrides).length === 0) delete next[matchId]; | |
| else next[matchId] = matchOverrides; | |
| return next; | |
| }); | |
| }; | |
| const handleRemoveEntireOverride = (matchId) => { | |
| setFixtureOverrides(prev => { | |
| const next = { ...prev }; | |
| delete next[matchId]; | |
| return next; | |
| }); | |
| }; | |
| return ( | |
| <div className="flex flex-col gap-4 flex-1 h-full"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between"> | |
| <p className="text-[10px] text-slate-500 leading-relaxed max-w-[70%]"> | |
| Override schedules & EV splits. Only customized fixtures are displayed below. | |
| </p> | |
| {Object.keys(fixtureOverrides).length > 0 && ( | |
| <button onClick={() => { if(window.confirm("Reset all?")) setFixtureOverrides({}); }} className="text-[10px] font-bold text-red-400 hover:text-red-300 uppercase tracking-wider bg-red-500/10 px-2 py-1 rounded transition-colors"> | |
| Reset All | |
| </button> | |
| )} | |
| </div> | |
| {/* Fixture Search Bar & Admin Tools */} | |
| <div className="relative flex gap-2"> | |
| <div className="flex-1 flex items-center bg-slate-900 border border-slate-700 rounded-lg px-3 focus-within:border-indigo-500 transition-colors shadow-inner relative"> | |
| {/* Secret Click Zone */} | |
| <div className="absolute left-0 w-10 h-full flex items-center justify-center cursor-pointer z-dropdown" onClick={handleSecretClick}> | |
| <Search size={14} className="text-slate-500 pointer-events-none" /> | |
| </div> | |
| <input | |
| type="text" | |
| placeholder="Search team to add override..." | |
| value={search} | |
| onChange={(e) => setSearch(e.target.value)} | |
| className="w-full bg-transparent py-2 pl-6 text-xs font-bold text-slate-200 outline-none" | |
| /> | |
| {search && <button onClick={() => setSearch("")} className="text-slate-500 hover:text-white z-dropdown"><X size={14}/></button>} | |
| </div> | |
| {/* Admin Login UI */} | |
| {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-xs w-28 outline-none focus:border-orange-500 text-slate-200" /> | |
| <button onClick={() => setIsAdmin(true)} className="bg-slate-700 hover:bg-slate-600 px-3 rounded text-xs text-white transition-colors">Login</button> | |
| </div> | |
| )} | |
| {isAdmin && ( | |
| <button onClick={handlePublishGlobal} className="animate-in fade-in zoom-in bg-orange-600 hover:bg-orange-500 text-white font-bold text-xs px-3 py-1.5 rounded shadow-lg transition-colors flex items-center gap-1 ml-2"> | |
| <Database size={12} /> Publish Globally | |
| </button> | |
| )} | |
| {/* Search Results Dropdown */} | |
| {search && ( | |
| <div className="absolute top-full left-0 w-full mt-1 bg-slate-800 border border-slate-600 rounded-lg shadow-2xl overflow-hidden z-popover"> | |
| {searchResults.length === 0 ? ( | |
| <div className="p-3 text-xs text-slate-400 italic">No matches found...</div> | |
| ) : ( | |
| searchResults.map(match => ( | |
| <button | |
| key={match.id} | |
| onClick={() => handleAddOverride(match)} | |
| className="w-full flex items-center justify-between p-3 border-b border-slate-700/50 hover:bg-slate-700 transition-colors text-left" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs font-black text-slate-200">{match.homeTeam}</span> | |
| <span className="text-[9px] text-slate-500 font-bold uppercase">vs</span> | |
| <span className="text-xs font-black text-slate-200">{match.awayTeam}</span> | |
| </div> | |
| <span className="text-[10px] font-bold text-slate-400 bg-slate-900 px-2 py-1 rounded">GW{match.defaultGw}</span> | |
| </button> | |
| )) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| {/* Active Overrides List */} | |
| <div className="flex flex-col gap-3 overflow-y-auto custom-scrollbar pr-2 pb-4"> | |
| {activeSplits.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-10 opacity-40"> | |
| <Zap size={32} className="text-slate-500 mb-2" /> | |
| <span className="text-xs font-bold text-slate-400 uppercase tracking-widest">No Active Overrides</span> | |
| </div> | |
| ) : ( | |
| activeSplits.map(match => { | |
| const overrides = effectiveFixtures[match.id]; | |
| return ( | |
| <div key={match.id} className="flex flex-col bg-slate-900 border border-indigo-500/50 shadow-[0_0_15px_rgba(99,102,241,0.1)] rounded-xl overflow-hidden"> | |
| {/* Match Header */} | |
| <div className="flex items-center justify-between px-3 py-2 bg-slate-950/50 border-b border-indigo-500/20"> | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs font-black text-slate-300">{match.homeTeam}</span> | |
| <span className="text-[9px] text-slate-600 font-bold uppercase tracking-widest">vs</span> | |
| <span className="text-xs font-black text-slate-300">{match.awayTeam}</span> | |
| </div> | |
| <button onClick={() => handleRemoveEntireOverride(match.id)} className="text-slate-500 hover:text-red-400 transition-colors" title="Remove Override"> | |
| <X size={14} /> | |
| </button> | |
| </div> | |
| {/* Sliders / Override UI */} | |
| <div className="p-3 flex flex-col gap-3 bg-indigo-950/10"> | |
| {Object.entries(overrides).map(([gw, prob]) => { | |
| // Check if this row is an empty placeholder waiting for a selection | |
| const isUnselected = gw.startsWith('unselected'); | |
| return ( | |
| <div key={gw} className="flex items-center gap-3"> | |
| <select | |
| value={isUnselected ? "" : gw} | |
| onChange={(e) => handleChangeSplitGw(match.id, gw, e.target.value)} | |
| className={`bg-slate-950 border text-xs font-bold rounded px-1 py-1 outline-none w-[76px] transition-colors ${isUnselected ? 'border-dashed border-indigo-500/80 text-indigo-200' : 'border-indigo-500/30 text-indigo-300'}`} | |
| > | |
| {isUnselected && <option value="" disabled>Select GW</option>} | |
| {availableGWs.map(g => { | |
| // Don't show gameweeks that are already selected in another row for this match | |
| const isAlreadySelected = Object.keys(overrides).includes(String(g)) && String(g) !== String(gw); | |
| return !isAlreadySelected && <option key={g} value={g}>GW{g}</option>; | |
| })} | |
| </select> | |
| {/* 1% Step Sliders, locked if no GW is selected */} | |
| <input | |
| type="range" min="0" max="1" step="0.01" | |
| value={prob} | |
| disabled={isUnselected} | |
| onChange={(e) => handleUpdateSplit(match.id, gw, e.target.value)} | |
| className={`flex-1 accent-indigo-500 ${isUnselected ? 'opacity-30 cursor-not-allowed' : 'cursor-ew-resize'}`} | |
| /> | |
| {/* Editable Number Input with rounding and boundaries */} | |
| <div className="relative flex items-center"> | |
| <input | |
| type="number" | |
| min="0" | |
| max="100" | |
| step="1" | |
| disabled={isUnselected} | |
| value={Math.round(prob * 100)} | |
| onChange={(e) => { | |
| let val = Math.round(Number(e.target.value)); | |
| if (isNaN(val)) val = 0; | |
| if (val > 100) val = 100; | |
| if (val < 0) val = 0; | |
| handleUpdateSplit(match.id, gw, val / 100); | |
| }} | |
| className={`w-14 bg-slate-900 border border-indigo-500/30 text-indigo-300 text-xs font-bold rounded px-1 py-1 outline-none text-right pr-4 transition-colors ${isUnselected ? 'opacity-30 cursor-not-allowed' : 'hover:border-indigo-500/80 focus:border-indigo-400'}`} | |
| /> | |
| <span className={`absolute right-1.5 text-[10px] font-bold text-indigo-400 pointer-events-none ${isUnselected ? 'opacity-30' : ''}`}>%</span> | |
| </div> | |
| <button onClick={() => handleRemoveSplit(match.id, gw)} className="text-slate-600 hover:text-red-400 p-1"> | |
| <Trash2 size={14} /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| {/* Footer: Add Row & EV Sum Validator */} | |
| <div className="flex justify-between items-center mt-1 border-t border-indigo-500/20 pt-2"> | |
| <button onClick={() => handleAddSplitGw(match.id)} className="flex items-center gap-1 text-[9px] font-bold uppercase tracking-wider text-indigo-400 hover:text-indigo-300 transition-colors bg-indigo-900/30 px-2 py-1 rounded"> | |
| <Plus size={12} /> Add GW Split | |
| </button> | |
| {(() => { | |
| const totalProb = Object.values(overrides).reduce((a, b) => a + b, 0); | |
| const isBalanced = Math.abs(totalProb - 1.0) < 0.01; | |
| return ( | |
| <span className={`text-[9px] font-bold uppercase tracking-wider flex items-center gap-1 ${isBalanced ? 'text-emerald-500' : 'text-red-500'}`}> | |
| {isBalanced ? <Zap size={10} /> : null} | |
| Total: {Math.round(totalProb * 100)}% | |
| </span> | |
| ); | |
| })()} | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; |