feat: PWA auction assistant — mobile-first progressive web app

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
This commit is contained in:
ramseshk
2026-08-12 12:38:00 +08:00
parent 2c818e292a
commit 35370c81f8
9 changed files with 1044 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
// 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);