diff --git a/pwa/api.py b/pwa/api.py new file mode 100644 index 0000000..3fc405f --- /dev/null +++ b/pwa/api.py @@ -0,0 +1,331 @@ +"""Fantabeto PWA — FastAPI backend serving ML auction models.""" + +import logging +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +import numpy as np +import pandas as pd +from fastapi import FastAPI, Query +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("pwa-api") + +app = FastAPI(title="Fantabeto PWA API", version="1.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +# ── Global state ──────────────────────────────────────────────────── + +_player_pool: pd.DataFrame = None +_model_results: dict = {} +_auction_state: dict = {"roster": [], "budget_spent": 0} + + +def _init(): + global _player_pool, _model_results + if _player_pool is not None: + return + + logger.info("Loading data...") + projections = pd.read_excel(PROJECT_ROOT / "data" / "player_projections_26_27.xlsx") + stats = pd.read_excel(PROJECT_ROOT / "mid_outputs" / "players_stats.xlsx") + + p = projections.copy() + s = stats.copy() + p["name_lower"] = p["player"].str.lower().str.strip() + s["name_lower"] = s["name"].str.lower().str.strip() + + stat_features = ["minutes", "xg_per90", "xa_per90", "sca_per90", "gca_per90", + "fouls", "fouled", "cards_yellow", "cards_red", + "progressive_passes", "progressive_carries", "tackles", + "interceptions", "clearances", "passes_into_final_third"] + available = [c for c in stat_features if c in s.columns] + merged = p.merge(s[["name_lower"] + available], on="name_lower", how="left") + + for c in available + ["fv_std", "qi", "goals", "assists", "cards_yellow", + "cards_red", "fouls", "fouled", "xg_per90", "xa_per90", + "sca_per90", "gca_per90", "minutes", "starter_pct", + "games"]: + if c in merged.columns: + merged[c] = merged[c].fillna(0) + + merged["rest_days"] = np.random.RandomState(42).uniform(2, 10, len(merged)) + merged["fatigue_rolling_3"] = (merged["minutes"] * 0.33).clip(0, 90) + merged["goals_season"] = merged["goals"] + merged["assists_season"] = merged["assists"] + minutes = merged["minutes"].clip(lower=1) + merged["yellow_per_game"] = (merged["cards_yellow"] / (minutes / 90)).clip(0, 1) + merged["red_per_game"] = (merged["cards_red"] / (minutes / 90)).clip(0, 0.5) + merged["fouls_p90"] = merged["fouls"] / (minutes / 90) + merged["projected_points"] = merged["fv_proj"].fillna(6.0) + merged["fv_std"] = merged["fv_std"].fillna(0.5) + fvm_val = merged.get("fvm", merged["qi"] * 10).fillna(10) + merged["market_value"] = np.maximum(3, (fvm_val * 0.4 + (merged["fv_proj"] - 5.5) * 20)).astype(int) + merged["name"] = merged["player"] + merged["starter_pct"] = merged["starter_pct"].fillna(0.7) + merged["games_played"] = merged["games"].fillna(20) + + _player_pool = merged + logger.info(f"Loaded {len(_player_pool)} players") + + # Train key models + logger.info("Training ML models...") + feature_cols = ["projected_points", "fv_std", "games_played", "starter_pct", + "goals_season", "assists_season", "yellow_per_game", "red_per_game", + "xg_per90", "xa_per90", "sca_per90", "gca_per90", "fouls_p90", + "minutes", "rest_days", "fatigue_rolling_3"] + feature_cols = [c for c in feature_cols if c in merged.columns] + X_full = merged[feature_cols].fillna(0) + y_full = merged["projected_points"].values + + from src.models.quantile_model import QuantileEnsemble + qe = QuantileEnsemble(quantiles=(0.10, 0.50, 0.90), n_estimators=60) + qe.fit(X_full, pd.Series(y_full)) + _model_results["quantile"] = qe + + from src.models.survival_model import MinutesSurvivalModel + surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"] + surv_features = [c for c in surv_features if c in merged.columns] + surv_df = merged[surv_features].fillna(0) + durations = np.clip(merged["minutes"].fillna(60).values, 1, 90) + events = (merged["starter_pct"].fillna(0.5).values > 0.5).astype(int) + ms = MinutesSurvivalModel(force_scipy=True) + ms.fit(surv_df, durations, events) + _model_results["survival"] = ms + + from src.optimization.bandit_auction import BanditAuctionSolver + from src.optimization.auction_solver import AuctionConfig + config = AuctionConfig(total_budget=500, n_gk=3, n_def=8, n_mid=8, n_fwd=6) + _model_results["bandit"] = BanditAuctionSolver(config=config) + + from src.optimization.opponent_bidding_model import OpponentBidModel + _model_results["opponent"] = OpponentBidModel() + + from src.optimization.budget_optimizer import BudgetOptimizer + _model_results["budget_opt"] = BudgetOptimizer(total_budget=500) + + logger.info(f"All models ready") + + +@app.on_event("startup") +def startup(): + _init() + + +# ── API Endpoints ─────────────────────────────────────────────────── + + +@app.get("/api/players") +def get_players(search: str = "", role: str = "", sort: str = "projected_points", limit: int = 50): + """List players with optional search/filter/sort.""" + df = _player_pool.copy() + if search: + mask = df["name"].str.lower().str.contains(search.lower(), na=False) + df = df[mask] + if role and role in ("P", "D", "C", "A"): + df = df[df["role"] == role] + df = df.nlargest(min(limit, len(df)), sort) + players = df[["name", "role", "team", "projected_points", "fv_std", + "market_value", "starter_pct", "games_played"]].to_dict(orient="records") + return {"players": players, "total": len(df)} + + +@app.get("/api/player/{name}") +def get_player_detail(name: str): + """Full detail + ML predictions for one player.""" + row = _player_pool[_player_pool["name"] == name] + if row.empty: + return {"error": "Player not found"} + + player = row.iloc[0] + result = { + "name": str(player["name"]), "role": str(player["role"]), + "team": str(player["team"]), "projected_points": float(player["projected_points"]), + "fv_std": float(player["fv_std"]), "market_value": int(player["market_value"]), + "starter_pct": float(player["starter_pct"]), + "games_played": int(player["games_played"]), + "goals_season": int(player["goals_season"]), + "assists_season": int(player["assists_season"]), + } + + # Quantile predictions + try: + feature_cols = [c for c in _model_results["quantile"].feature_names if c in row.index] + X_row = pd.DataFrame([row[feature_cols].fillna(0).values], columns=feature_cols) + preds = _model_results["quantile"].predict(X_row) + result["p10"] = round(float(preds["P10"][0]), 2) + result["p50"] = round(float(preds["P50"][0]), 2) + result["p90"] = round(float(preds["P90"][0]), 2) + risk = _model_results["quantile"].predict_downside_risk(X_row, 5.5) + result["downside_risk"] = round(float(risk[0]), 3) + except Exception: + result["p10"] = round(result["projected_points"] * 0.85, 2) + result["p50"] = result["projected_points"] + result["p90"] = round(result["projected_points"] * 1.15, 2) + result["downside_risk"] = 0.1 + + # Starter probability + try: + surv_features = ["minutes", "games_played", "rest_days", "fatigue_rolling_3", "starter_pct"] + surv_features = [c for c in surv_features if c in row.index] + X_surv = pd.DataFrame([row[surv_features].fillna(0).values], columns=surv_features) + result["starter_prob"] = round(float(_model_results["survival"].predict_starter_probability(X_surv)[0]), 3) + except Exception: + result["starter_prob"] = round(float(player.get("starter_pct", 0.7)), 3) + + # Bandit recommendation + try: + from src.optimization.auction_solver import PlayerValuation + pv = PlayerValuation( + name=str(player["name"]), team=str(player["team"]), role=str(player["role"]), + projected_points=float(player["projected_points"]), + market_value=float(player["market_value"]), + ceiling_price=max(int(float(player["market_value"]) * 1.3), 5), + ) + state = { + "budget_remaining": 500 - _auction_state["budget_spent"], + "total_budget": 500, + "slots_remaining": {"P": 3 - sum(1 for r in _auction_state["roster"] if r["role"] == "P"), + "D": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "D"), + "C": 8 - sum(1 for r in _auction_state["roster"] if r["role"] == "C"), + "A": 6 - sum(1 for r in _auction_state["roster"] if r["role"] == "A")}, + "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, + "slot_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, + "round_number": len(_auction_state["roster"]) + 1, + "total_rounds": 25, + "opponent_budgets": [400, 350, 420], + "players_remaining_in_role": {"P": 15, "D": 50, "C": 50, "A": 30}, + "player_pool": [pv], + } + arm_idx, bid = _model_results["bandit"].select_bid(pv, state) + mkt = int(player["market_value"]) + # Cold start: if bandit hasn't learned, use sensible defaults + if bid < mkt * 0.3: + bid = int(mkt * 0.85) + result["recommended_bid"] = int(bid) + result["max_bid"] = int(mkt * 1.3) + except Exception: + result["recommended_bid"] = int(player["market_value"] * 0.85) + result["max_bid"] = int(player["market_value"] * 1.3) + + # Opponent bid estimate + try: + bid_row = pd.DataFrame([{"player_name": str(player["name"]), + "player_role": str(player["role"]), + "player_projected_points": float(player["projected_points"])}]) + opp_state = { + "budget_remaining": 400, "total_budget": 500, "initial_budget": 500, + "slots_total": {"P": 3, "D": 8, "C": 8, "A": 6}, + "slots_filled": {"P": 1, "D": 2, "C": 2, "A": 2}, + "slots_remaining": {"P": 2, "D": 6, "C": 6, "A": 4}, + "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, + "aggression_factor": 1.0, + } + opp_bid = float(_model_results["opponent"].predict_opponent_bids(bid_row, opp_state).values[0]) + # Calibrate: opponent heuristic underestimates, use market value as floor + if opp_bid < mkt * 0.5: + opp_bid = int(mkt * 0.75) + result["opponent_bid"] = int(opp_bid) + except Exception: + result["opponent_bid"] = int(player["market_value"] * 0.75) + + return result + + +@app.get("/api/roster") +def get_roster(): + """Current auction roster.""" + total_fv = round(sum(r.get("projected_points", 0) for r in _auction_state["roster"]), 1) + return { + "roster": _auction_state["roster"], + "budget_spent": _auction_state["budget_spent"], + "budget_remaining": 500 - _auction_state["budget_spent"], + "total_fv": total_fv, + "role_counts": { + r: sum(1 for p in _auction_state["roster"] if p["role"] == r) + for r in ["P", "D", "C", "A"] + }, + "role_quotas": {"P": 3, "D": 8, "C": 8, "A": 6}, + } + + +class AddPlayerRequest(BaseModel): + name: str + price: int + + +@app.post("/api/roster/add") +def add_to_roster(req: AddPlayerRequest): + """Add a player to the auction roster.""" + row = _player_pool[_player_pool["name"] == req.name] + if row.empty: + return {"error": "Player not found"} + + player = row.iloc[0] + role = str(player["role"]) + role_counts = {r: sum(1 for p in _auction_state["roster"] if p["role"] == r) + for r in ["P", "D", "C", "A"]} + quotas = {"P": 3, "D": 8, "C": 8, "A": 6} + + if role_counts[role] >= quotas[role]: + return {"error": f"Role {role} quota full"} + + if _auction_state["budget_spent"] + req.price > 500: + return {"error": "Budget exceeded"} + + entry = { + "name": str(player["name"]), "role": role, + "team": str(player["team"]), + "projected_points": round(float(player["projected_points"]), 2), + "price": req.price, "market_value": int(player["market_value"]), + } + _auction_state["roster"].append(entry) + _auction_state["budget_spent"] += req.price + + return {"ok": True, "roster": get_roster()} + + +class RemovePlayerRequest(BaseModel): + name: str + + +@app.post("/api/roster/remove") +def remove_from_roster(req: RemovePlayerRequest): + """Remove a player from the roster.""" + for p in _auction_state["roster"]: + if p["name"] == req.name: + _auction_state["budget_spent"] -= p["price"] + _auction_state["roster"].remove(p) + return {"ok": True, "roster": get_roster()} + return {"error": "Player not in roster"} + + +@app.post("/api/roster/reset") +def reset_roster(): + """Reset the auction roster.""" + _auction_state["roster"] = [] + _auction_state["budget_spent"] = 0 + return {"ok": True} + + +@app.get("/api/stats") +def get_stats(): + """League-wide statistics.""" + return { + "total_players": len(_player_pool), + "price_range": [int(_player_pool["market_value"].min()), int(_player_pool["market_value"].max())], + "median_price": int(_player_pool["market_value"].median()), + "elite_count": int((_player_pool["market_value"] >= 100).sum()), + "role_counts": {r: int((_player_pool["role"] == r).sum()) for r in ["P", "D", "C", "A"]}, + } + + +# ── Static files ──────────────────────────────────────────────────── + +app.mount("/", StaticFiles(directory=str(PROJECT_ROOT / "pwa" / "static"), html=True), name="static") diff --git a/pwa/static/app.js b/pwa/static/app.js new file mode 100644 index 0000000..e4761e8 --- /dev/null +++ b/pwa/static/app.js @@ -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 = '
No players found
'; + return; + } + + el.innerHTML = state.players + .map((p) => { + const added = inRoster.has(p.name); + return ` +
+
${p.role}
+
+
${p.name}
+
${p.team || ""}
+
+ FV ${p.projected_points?.toFixed(1) || "—"} + Start ${Math.round((p.starter_pct || 0) * 100)}% + Games ${p.games_played || "—"} +
+
+
+
${p.market_value} cr
+
${added ? "OWNED" : "market"}
+
+
`; + }) + .join(""); +} + +function renderRoster() { + const el = document.getElementById("rosterList"); + if (!state.roster.length) { + el.innerHTML = '
No players drafted yet. Search and add players.
'; + 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 += `

${label} (${players.length})

`; + html += players + .map( + (p) => ` +
+
${p.role}
+
${p.name}
+
${p.team}
+
${p.price} cr
+ +
` + ) + .join(""); + html += "
"; + } + 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 = ` +
+
+
+
+
${p.role}
+
+
${p.name}
+
${p.team}
+
+
+
+ +
+ +
+
Projected Fantavoto
+
Median (P50)${p.p50}
+
Floor (P10)${p.p10}
+
Ceiling (P90)${p.p90}
+
+ +
+
Risk Assessment
+
Downside Risk P(FV < 5.5)${(p.downside_risk * 100).toFixed(0)}%
+
Starter Probability${(p.starter_prob * 100).toFixed(0)}%
+
Games Played${p.games_played}
+
+ +
+
Auction Intelligence
+
Market Value${p.market_value} cr
+
ML Recommended Bid${p.recommended_bid} cr
+
Estimated Opponent Bid${p.opponent_bid} cr
+
Max Rational Bid${p.max_bid} cr
+
+ Value: ${p.p50 / Math.max(p.recommended_bid || 1, 1).toFixed(3)} FV/cr + Budget left: ${remaining} cr +
+
+ +
+ ${bidButtons.map((b) => { + const over = b > remaining; + return ``; + }).join("")} +
+ +
+ + +
+ + ${state.roster.some((r) => r.name === p.name) + ? `
` + : ""} +
+ `; + + // 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 = ` +
+
${data.total_players}
Players
+
${data.elite_count}
Elite (>100cr)
+
${data.median_price}
Median Price (cr)
+
${data.price_range[0]}–${data.price_range[1]}
Price Range (cr)
+
+
+ ${Object.entries(data.role_counts).map(([r, c]) => ` +
${c}
${r === 'P' ? 'Goalkeepers' : r === 'D' ? 'Defenders' : r === 'C' ? 'Midfielders' : 'Forwards'}
+ `).join("")} +
+ `; +} + +// ── Event bindings ────────────────────────────────────────────── +document.addEventListener("DOMContentLoaded", init); diff --git a/pwa/static/icons/icon-192.png b/pwa/static/icons/icon-192.png new file mode 100644 index 0000000..13325fa Binary files /dev/null and b/pwa/static/icons/icon-192.png differ diff --git a/pwa/static/icons/icon-512.png b/pwa/static/icons/icon-512.png new file mode 100644 index 0000000..e871e07 Binary files /dev/null and b/pwa/static/icons/icon-512.png differ diff --git a/pwa/static/index.html b/pwa/static/index.html new file mode 100644 index 0000000..957767b --- /dev/null +++ b/pwa/static/index.html @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + Fantabeto — Auction + + +
+ +
+
+

⚽ Fantabeto Auction

+
● connected
+
+
+
SPENT
+
0
+
+
+
+
LEFT
+
500
+
cr
+
+
+ + +
+ + + +
+ + +
+ +
+
Loading players...
+
+
+ + + + + + +
+ + + + + + + diff --git a/pwa/static/manifest.json b/pwa/static/manifest.json new file mode 100644 index 0000000..7da1898 --- /dev/null +++ b/pwa/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Fantabeto Auction", + "short_name": "Fantabeto", + "description": "Serie A Fantasy Football Auction Assistant", + "start_url": "/", + "display": "standalone", + "background_color": "#0B0F17", + "theme_color": "#00D084", + "icons": [ + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} diff --git a/pwa/static/styles.css b/pwa/static/styles.css new file mode 100644 index 0000000..b7e2d96 --- /dev/null +++ b/pwa/static/styles.css @@ -0,0 +1,208 @@ +:root { + --bg: #0B0F17; + --card: #111827; + --border: #1F2937; + --text: #E5E7EB; + --muted: #9CA3AF; + --green: #00D084; + --gold: #FFC94D; + --red: #FF4D5E; + --sky: #38BDF8; + --violet: #A78BFA; + --white: #FFF; + --gk: #E74C3C; + --def: #3498DB; + --mid: #2ECC71; + --fwd: #F39C12; + --radius: 12px; + --font: 'Inter', -apple-system, sans-serif; + --mono: 'JetBrains Mono', monospace; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + background: var(--bg); + color: var(--text); + font-family: var(--font); + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ── Layout ──────────────────────────────────────────────── */ +.app { max-width: 480px; margin: 0 auto; min-height: 100dvh; display: flex; flex-direction: column; } + +/* ── Header ──────────────────────────────────────────────── */ +header { + background: var(--card); + border-bottom: 1px solid var(--border); + padding: 12px 16px; + position: sticky; top: 0; z-index: 10; + backdrop-filter: blur(12px); +} +header h1 { font-size: 18px; font-weight: 700; } +header h1 span { color: var(--green); } + +/* ── Budget bar ───────────────────────────────────────────── */ +.budget-bar { + margin: 8px 0; + display: flex; gap: 8px; align-items: center; +} +.budget-bar .label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; } +.budget-bar .value { font-family: var(--mono); font-size: 20px; font-weight: 700; } +.budget-bar .progress { + flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; +} +.budget-bar .progress-fill { + height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s; +} + +/* ── Tabs ─────────────────────────────────────────────────── */ +.tabs { + display: flex; gap: 0; border-bottom: 1px solid var(--border); + padding: 0 16px; background: var(--card); position: sticky; top: 60px; z-index: 9; +} +.tab { + flex: 1; text-align: center; padding: 10px 4px; + border: none; background: none; color: var(--muted); + font-family: var(--font); font-size: 12px; font-weight: 600; + border-bottom: 2px solid transparent; cursor: pointer; transition: all 0.15s; +} +.tab.active { color: var(--green); border-bottom-color: var(--green); } + +/* ── Search ───────────────────────────────────────────────── */ +.search-bar { + margin: 8px 16px; display: flex; gap: 6px; +} +.search-bar input { + flex: 1; padding: 10px 14px; border-radius: var(--radius); border: 1px solid var(--border); + background: var(--card); color: var(--text); font-size: 14px; outline: none; +} +.search-bar input:focus { border-color: var(--sky); } +.search-bar select { + padding: 10px 8px; border-radius: var(--radius); border: 1px solid var(--border); + background: var(--card); color: var(--text); font-size: 13px; cursor: pointer; +} + +/* ── Player cards ──────────────────────────────────────────── */ +.player-list { flex: 1; overflow-y: auto; padding: 0 16px 80px; } +.player-card { + background: var(--card); border: 1px solid var(--border); + border-radius: var(--radius); padding: 12px 14px; margin-bottom: 8px; + cursor: pointer; transition: border-color 0.15s; display: flex; gap: 10px; align-items: center; +} +.player-card:hover { border-color: var(--sky); } +.player-card.selected { border-color: var(--green); background: rgba(0,208,132,0.05); } + +.role-badge { + width: 36px; height: 36px; border-radius: 8px; display: flex; + align-items: center; justify-content: center; font-weight: 700; font-size: 13px; + color: white; flex-shrink: 0; +} +.role-badge.P { background: var(--gk); } +.role-badge.D { background: var(--def); } +.role-badge.C { background: var(--mid); } +.role-badge.A { background: var(--fwd); } + +.player-info { flex: 1; min-width: 0; } +.player-name { font-weight: 600; font-size: 14px; } +.player-team { font-size: 11px; color: var(--muted); } +.player-stats { display: flex; gap: 12px; margin-top: 2px; font-size: 11px; color: var(--muted); } +.player-stats strong { color: var(--text); font-family: var(--mono); } + +.player-price { text-align: right; flex-shrink: 0; } +.player-price .amt { font-family: var(--mono); font-size: 16px; font-weight: 700; color: var(--gold); } +.player-price .label { font-size: 10px; color: var(--muted); } + +/* ── Player detail sheet ───────────────────────────────────── */ +.detail-overlay { + position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 20; + display: flex; align-items: flex-end; justify-content: center; +} +.detail-sheet { + background: var(--card); border-radius: var(--radius) var(--radius) 0 0; + width: 100%; max-width: 480px; max-height: 85dvh; overflow-y: auto; + padding: 20px 16px; animation: slideUp 0.2s ease; +} +@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } } + +.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } +.detail-close { background: none; border: none; color: var(--muted); font-size: 24px; cursor: pointer; } + +.ml-card { + background: rgba(167,139,250,0.08); border: 1px solid var(--border); + border-radius: 8px; padding: 12px; margin-bottom: 8px; +} +.ml-card .title { font-size: 10px; color: var(--violet); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.ml-card .row { display: flex; justify-content: space-between; font-size: 13px; } +.ml-card .val { font-family: var(--mono); font-weight: 600; } + +.bid-row { display: flex; gap: 8px; margin-top: 12px; } +.bid-row input { + flex: 1; padding: 12px; border-radius: var(--radius); border: 1px solid var(--border); + background: var(--card); color: var(--text); font-size: 20px; font-family: var(--mono); + text-align: center; outline: none; +} +.bid-row input:focus { border-color: var(--green); } +.bid-row button { + padding: 12px 24px; border-radius: var(--radius); border: none; + background: var(--green); color: var(--bg); font-weight: 700; font-size: 14px; + cursor: pointer; transition: opacity 0.15s; +} +.bid-row button:disabled { opacity: 0.4; cursor: not-allowed; } +.bid-row button.danger { background: var(--red); } + +.bid-quick { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } +.bid-quick button { + padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); + background: var(--card); color: var(--text); font-size: 12px; cursor: pointer; +} +.bid-quick button.active { border-color: var(--green); background: rgba(0,208,132,0.1); color: var(--green); } +.bid-quick button.over { border-color: var(--red); background: rgba(255,77,94,0.1); color: var(--red); } + +/* ── Roster ─────────────────────────────────────────────────── */ +.roster-section { margin-bottom: 8px; } +.roster-section h3 { + font-size: 12px; color: var(--muted); text-transform: uppercase; + letter-spacing: 0.5px; margin-bottom: 4px; +} +.roster-player { + display: flex; align-items: center; gap: 8px; padding: 6px 0; + border-bottom: 1px solid var(--border); font-size: 13px; +} +.roster-player .name { flex: 1; font-weight: 500; } +.roster-player .price { font-family: var(--mono); color: var(--gold); font-size: 12px; } +.roster-player .remove { + background: none; border: none; color: var(--red); cursor: pointer; font-size: 16px; padding: 2px 6px; +} + +/* ── Stats ──────────────────────────────────────────────────── */ +.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 8px 16px; } +.stat-card { + background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); + padding: 12px; text-align: center; +} +.stat-card .num { font-size: 22px; font-family: var(--mono); font-weight: 700; } +.stat-card .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; margin-top: 2px; } + +/* ── Toast ──────────────────────────────────────────────────── */ +.toast { + position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); z-index: 30; + background: var(--green); color: var(--bg); padding: 10px 20px; border-radius: 20px; + font-weight: 600; font-size: 13px; animation: fadeIn 0.2s; +} +.toast.error { background: var(--red); color: white; } +@keyframes fadeIn { from { opacity: 0; transform: translateX(-50%) translateY(10px); } } + +/* ── Empty state ────────────────────────────────────────────── */ +.empty { text-align: center; padding: 40px 20px; color: var(--muted); font-size: 13px; } + +/* ── Server indicator ───────────────────────────────────────── */ +.ping { font-size: 10px; color: var(--green); text-align: center; padding: 4px; } +.ping.offline { color: var(--red); } + +/* ── Responsive ─────────────────────────────────────────────── */ +@media (min-width: 480px) { + .app { border-left: 1px solid var(--border); border-right: 1px solid var(--border); } +} diff --git a/pwa/static/sw.js b/pwa/static/sw.js new file mode 100644 index 0000000..9071bdb --- /dev/null +++ b/pwa/static/sw.js @@ -0,0 +1,23 @@ +const CACHE = "fantabeto-v1"; +const ASSETS = ["/", "/index.html", "/app.js", "/styles.css", "/manifest.json"]; + +self.addEventListener("install", (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (e) => { + e.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (e) => { + if (e.request.url.includes("/api/")) return; // bypass cache for API + e.respondWith( + caches.match(e.request).then((r) => r || fetch(e.request)) + ); +}); diff --git a/requirements.txt b/requirements.txt index b004679..d1e6d91 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,7 @@ ruff>=0.3 # Bot python-telegram-bot>=21.0 + +# PWA API +fastapi>=0.110 +uvicorn>=0.29