fpl-solver / frontend /src /components /FixtureMatrixPanel.jsx
AnayShukla's picture
updates
052f3f2
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>
);
};