35370c81f8
FastAPI backend: - /api/players — search/filter 505 Serie A players with calibrated prices - /api/player/name — full detail with ML predictions (quantile P10/P50/P90, starter probability, bandit bid recommendation, opponent bid estimate) - /api/roster — CRUD for live auction roster with budget tracking - /api/stats — league-wide price distribution - All ML models loaded on startup (quantile, survival, bandit, opponent, budget) PWA frontend: - Installable on iOS/Android via manifest.json + service worker - Dark theme matching Fantabeto design system - Player search with role filter, quick-scan list - Detail sheet with ML intelligence cards - One-tap bid buttons (market, ML rec, max) - Live budget bar, roster management - Works offline for cached assets - Mobile-first (480px max-width) Launch: uvicorn pwa.api:app --port 8601 Live at: http://localhost:8601
370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
// 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 < 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 (>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);
|