Files

370 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Fantabeto PWA — Auction Assistant
const API = "/api";
const state = {
tab: "auction",
players: [],
roster: [],
budgetSpent: 0,
budgetTotal: 500,
selectedPlayer: null,
search: "",
roleFilter: "",
bidAmount: 0,
online: true,
};
// ── Init ────────────────────────────────────────────────────────
async function init() {
registerSW();
await loadRoster();
await searchPlayers();
await checkHealth();
setInterval(checkHealth, 30000);
}
function registerSW() {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js");
}
}
async function api(url, opts = {}) {
try {
const res = await fetch(url, opts);
state.online = true;
updatePing();
return await res.json();
} catch (e) {
state.online = false;
updatePing();
return null;
}
}
async function checkHealth() {
const r = await api(API + "/stats");
document.getElementById("ping").className = state.online ? "ping" : "ping offline";
document.getElementById("ping").textContent = state.online ? "● connected" : "○ offline";
}
function updatePing() {
const el = document.getElementById("ping");
if (!el) return;
el.className = state.online ? "ping" : "ping offline";
el.textContent = state.online ? "● connected" : "○ offline";
}
// ── Search ──────────────────────────────────────────────────────
async function searchPlayers() {
const params = new URLSearchParams({ limit: 60, sort: "projected_points" });
if (state.search) params.set("search", state.search);
if (state.roleFilter) params.set("role", state.roleFilter);
const data = await api(API + "/players?" + params);
state.players = data?.players || [];
renderPlayerList();
}
// ── Roster ──────────────────────────────────────────────────────
async function loadRoster() {
const data = await api(API + "/roster");
if (data) {
state.roster = data.roster || [];
state.budgetSpent = data.budget_spent || 0;
}
renderBudget();
renderRoster();
}
async function addToRoster(name, price) {
const data = await api(API + "/roster/add", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, price }),
});
if (data?.error) {
toast(data.error, "error");
return false;
}
if (data?.ok) {
state.roster = data.roster.roster;
state.budgetSpent = data.roster.budget_spent;
renderBudget();
renderRoster();
renderPlayerList();
toast(`${name} added · ${price} cr`);
return true;
}
return false;
}
async function removeFromRoster(name) {
const data = await api(API + "/roster/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
if (data?.ok) {
state.roster = data.roster.roster;
state.budgetSpent = data.roster.budget_spent;
renderBudget();
renderRoster();
renderPlayerList();
toast(`Removed ${name}`);
}
}
async function resetRoster() {
if (!confirm("Reset entire roster?")) return;
await api(API + "/roster/reset", { method: "POST" });
state.roster = [];
state.budgetSpent = 0;
renderBudget();
renderRoster();
renderPlayerList();
}
// ── Player detail ───────────────────────────────────────────────
async function showPlayer(name) {
const data = await api(API + "/player/" + encodeURIComponent(name));
if (!data) return;
state.selectedPlayer = data;
state.bidAmount = data.recommended_bid || 0;
renderDetail();
}
function closeDetail() {
state.selectedPlayer = null;
renderDetail();
}
function setBid(amount) {
state.bidAmount = amount;
document.getElementById("bidInput").value = amount;
document.querySelectorAll(".bid-quick button").forEach((b) => {
const v = parseInt(b.dataset.bid);
b.className = v === amount ? "active" : "";
});
}
function formatBidButtons(player) {
const bids = [];
const rec = player.recommended_bid || 0;
const max = player.max_bid || 0;
const mkt = player.market_value || 0;
if (mkt > 0) bids.push(mkt);
if (rec > 0 && rec !== mkt) bids.push(rec);
if (max > 0 && max !== rec && max !== mkt) bids.push(max);
// Ensure at least 3 options
if (bids.length < 3 && mkt > 10) bids.push(Math.round(mkt * 1.3));
return [...new Set(bids)].sort((a, b) => a - b);
}
// ── Render ──────────────────────────────────────────────────────
function renderBudget() {
const spent = state.budgetSpent;
const total = state.budgetTotal;
const pct = Math.min((spent / total) * 100, 100);
document.getElementById("budgetSpent").textContent = spent;
document.getElementById("budgetRemaining").textContent = total - spent;
document.getElementById("budgetFill").style.width = pct + "%";
document.getElementById("budgetFill").style.background =
pct > 90 ? "var(--red)" : pct > 70 ? "var(--gold)" : "var(--green)";
}
function renderPlayerList() {
const el = document.getElementById("playerList");
const inRoster = new Set(state.roster.map((p) => p.name));
if (!state.players.length) {
el.innerHTML = '<div class="empty">No players found</div>';
return;
}
el.innerHTML = state.players
.map((p) => {
const added = inRoster.has(p.name);
return `
<div class="player-card ${added ? "selected" : ""}" onclick="showPlayer('${p.name}')">
<div class="role-badge ${p.role}">${p.role}</div>
<div class="player-info">
<div class="player-name">${p.name}</div>
<div class="player-team">${p.team || ""}</div>
<div class="player-stats">
<span>FV <strong>${p.projected_points?.toFixed(1) || "—"}</strong></span>
<span>Start <strong>${Math.round((p.starter_pct || 0) * 100)}%</strong></span>
<span>Games <strong>${p.games_played || "—"}</strong></span>
</div>
</div>
<div class="player-price">
<div class="amt">${p.market_value} cr</div>
<div class="label">${added ? "OWNED" : "market"}</div>
</div>
</div>`;
})
.join("");
}
function renderRoster() {
const el = document.getElementById("rosterList");
if (!state.roster.length) {
el.innerHTML = '<div class="empty">No players drafted yet. Search and add players.</div>';
return;
}
const roleOrder = { P: "🧤 Goalkeepers", D: "🛡 Defenders", C: "⚙ Midfielders", A: "⚡ Forwards" };
let html = "";
for (const [role, label] of Object.entries(roleOrder)) {
const players = state.roster.filter((p) => p.role === role);
if (!players.length) continue;
html += `<div class="roster-section"><h3>${label} (${players.length})</h3>`;
html += players
.map(
(p) => `
<div class="roster-player">
<div class="role-badge ${p.role}" style="width:24px;height:24px;font-size:10px;">${p.role}</div>
<div class="name">${p.name}</div>
<div class="name" style="font-size:11px;color:var(--muted);">${p.team}</div>
<div class="price">${p.price} cr</div>
<button class="remove" onclick="removeFromRoster('${p.name}')">✕</button>
</div>`
)
.join("");
html += "</div>";
}
el.innerHTML = html;
}
function renderDetail() {
const el = document.getElementById("detailOverlay");
if (!state.selectedPlayer) {
el.style.display = "none";
return;
}
const p = state.selectedPlayer;
const bidButtons = formatBidButtons(p);
const remaining = state.budgetTotal - state.budgetSpent;
el.style.display = "flex";
el.innerHTML = `
<div class="detail-sheet" onclick="event.stopPropagation()">
<div class="detail-header">
<div>
<div style="display:flex;align-items:center;gap:8px;">
<div class="role-badge ${p.role}">${p.role}</div>
<div>
<div style="font-weight:700;font-size:18px;">${p.name}</div>
<div style="font-size:12px;color:var(--muted);">${p.team}</div>
</div>
</div>
</div>
<button class="detail-close" onclick="closeDetail()">✕</button>
</div>
<div class="ml-card">
<div class="title">Projected Fantavoto</div>
<div class="row"><span>Median (P50)</span><span class="val" style="color:var(--sky);">${p.p50}</span></div>
<div class="row"><span>Floor (P10)</span><span class="val" style="color:var(--red);">${p.p10}</span></div>
<div class="row"><span>Ceiling (P90)</span><span class="val" style="color:var(--green);">${p.p90}</span></div>
</div>
<div class="ml-card">
<div class="title">Risk Assessment</div>
<div class="row"><span>Downside Risk P(FV &lt; 5.5)</span><span class="val" style="color:${p.downside_risk > 0.2 ? 'var(--red)' : 'var(--green)'};">${(p.downside_risk * 100).toFixed(0)}%</span></div>
<div class="row"><span>Starter Probability</span><span class="val">${(p.starter_prob * 100).toFixed(0)}%</span></div>
<div class="row"><span>Games Played</span><span class="val">${p.games_played}</span></div>
</div>
<div class="ml-card" style="border-color:var(--gold);">
<div class="title" style="color:var(--gold);">Auction Intelligence</div>
<div class="row"><span>Market Value</span><span class="val" style="color:var(--gold);">${p.market_value} cr</span></div>
<div class="row"><span>ML Recommended Bid</span><span class="val" style="color:var(--green);">${p.recommended_bid} cr</span></div>
<div class="row"><span>Estimated Opponent Bid</span><span class="val" style="color:var(--red);">${p.opponent_bid} cr</span></div>
<div class="row"><span>Max Rational Bid</span><span class="val">${p.max_bid} cr</span></div>
<div class="row" style="margin-top:4px;font-size:11px;color:var(--muted);">
<span>Value: ${(p.p50 / Math.max(p.recommended_bid || 1, 1)).toFixed(3)} FV/cr</span>
<span>Budget left: ${remaining} cr</span>
</div>
</div>
<div class="bid-quick">
${bidButtons.map((b) => {
const over = b > remaining;
return `<button data-bid="${b}" class="${b === state.bidAmount ? 'active' : ''} ${over ? 'over' : ''}"
onclick="setBid(${b})">${b} cr</button>`;
}).join("")}
</div>
<div class="bid-row">
<input id="bidInput" type="number" value="${state.bidAmount}" min="1" max="${remaining}"
onchange="state.bidAmount=parseInt(this.value)||0">
<button onclick="doBid('${p.name}')" ${state.bidAmount > remaining ? "disabled" : ""}>
BUY
</button>
</div>
${state.roster.some((r) => r.name === p.name)
? `<div class="bid-row"><button class="danger" onclick="removeFromRoster('${p.name}');closeDetail();">REMOVE FROM ROSTER</button></div>`
: ""}
</div>
`;
// Focus bid input
setTimeout(() => {
const inp = document.getElementById("bidInput");
if (inp) { inp.focus(); inp.select(); }
}, 100);
}
// ── Actions ─────────────────────────────────────────────────────
async function doBid(name) {
const success = await addToRoster(name, state.bidAmount);
if (success) {
state.bidAmount = 0;
closeDetail();
}
}
function toast(msg, type = "") {
const el = document.createElement("div");
el.className = "toast" + (type === "error" ? " error" : "");
el.textContent = msg;
document.body.appendChild(el);
setTimeout(() => el.remove(), 2000);
}
// ── Tab switching ───────────────────────────────────────────────
function switchTab(tab) {
state.tab = tab;
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
document.getElementById("auctionView").style.display = tab === "auction" ? "block" : "none";
document.getElementById("rosterView").style.display = tab === "roster" ? "block" : "none";
document.getElementById("statsView").style.display = tab === "stats" ? "block" : "none";
if (tab === "stats") loadStats();
if (tab === "roster") loadRoster();
}
async function loadStats() {
const data = await api(API + "/stats");
if (!data) return;
document.getElementById("statsView").innerHTML = `
<div class="stat-grid">
<div class="stat-card"><div class="num" style="color:var(--sky);">${data.total_players}</div><div class="lbl">Players</div></div>
<div class="stat-card"><div class="num" style="color:var(--gold);">${data.elite_count}</div><div class="lbl">Elite (&gt;100cr)</div></div>
<div class="stat-card"><div class="num" style="color:var(--green);">${data.median_price}</div><div class="lbl">Median Price (cr)</div></div>
<div class="stat-card"><div class="num">${data.price_range[0]}–${data.price_range[1]}</div><div class="lbl">Price Range (cr)</div></div>
</div>
<div class="stat-grid" style="margin-top:8px;">
${Object.entries(data.role_counts).map(([r, c]) => `
<div class="stat-card"><div class="num" style="color:var(--${r === 'P' ? 'gk' : r === 'D' ? 'def' : r === 'C' ? 'mid' : 'fwd'});">${c}</div><div class="lbl">${r === 'P' ? 'Goalkeepers' : r === 'D' ? 'Defenders' : r === 'C' ? 'Midfielders' : 'Forwards'}</div></div>
`).join("")}
</div>
`;
}
// ── Event bindings ──────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", init);