/* QUANTA · Live data adapter Replaces mock-data.jsx with real WebSocket streams from the existing live_tennis (`/ws`) and live_football (`/football/ws`) backends. Shape rules: • Keeps the exact `window.MOCK` shape that the QUANTA components expect (BMS / TENNIS_MATCHES / FOOT_MATCHES / ALL_MATCHES / TOP_OPS / SPARK). • Arrays are mutated in place when a fresh snapshot arrives. • A `quanta-data-update` event is dispatched so QuantaAppAuthed bumps its render tick instantly (sub-second updates on goals, breaks, etc.). • Per-match rolling history (24 ticks per source per outcome) is built incrementally so the evolution chart in the detail view stays smooth between snapshots — the backend doesn't ship history; we synthesise it. No babel / no JSX — this file is plain JS so it loads before React. */ (function () { "use strict"; /* ────────────────────────────────────────────────────────── Bookmaker palette — must match the keys used downstream (QUANTA reads window.MOCK.BMS[name].color in many places). ────────────────────────────────────────────────────────── */ const BMS = { Betclic: { short: "BCL", color: "#ef4444" }, Winamax: { short: "WMX", color: "#0f172a" }, Yonibet: { short: "YBT", color: "#f59e0b" }, PMU: { short: "PMU", color: "#10b981" }, Unibet: { short: "UNI", color: "#16a34a" }, Betfair: { short: "BF", color: "#111111" }, Polymarket: { short: "PM", color: "#7c3aed" }, }; /* ────────────────────────────────────────────────────────── Initial shell — QUANTA reads this synchronously at boot. Empty arrays + neutral SPARK so the UI renders blank state without crashing while the first snapshot is in flight. ────────────────────────────────────────────────────────── */ window.MOCK = { BMS, TENNIS_MATCHES: [], FOOT_MATCHES: [], BASEBALL_MATCHES: [], ALL_MATCHES: [], TOP_OPS: [], SPARK: { t1_p1: [1, 1, 1], t1_p2: [1, 1, 1] }, // legacy field makeSpark: (start, n = 24) => Array(n).fill(start), }; const HISTORY_LEN = 24; /* ────────────────────────────────────────────────────────── History store keyed by `${sport}|${key}|${side}|${source}` Maintains a rolling deque of the last HISTORY_LEN odds. Side-effect: every NEW value (post-dedup) also lands in TICK_LOG so the stats ribbon can show real Ticks/min + Last tick figures. ────────────────────────────────────────────────────────── */ const HISTORY = new Map(); const TICK_LOG = { tennis: [], football: [], baseball: [] }; const TICK_WINDOW_MS = 60_000; function pushHistory(sport, key, side, source, value) { if (value == null || !Number.isFinite(value) || value <= 1.0) return; const k = `${sport}|${key}|${side}|${source}`; let arr = HISTORY.get(k); if (!arr) { arr = new Array(HISTORY_LEN).fill(value); HISTORY.set(k, arr); } // Skip duplicates so the chart doesn't flatline on quiet matches if (arr[arr.length - 1] === value) return; arr.push(value); while (arr.length > HISTORY_LEN) arr.shift(); // Tick log — "foot" key normalised to "football" so the UI can // query window.MOCK.getStats("football"). const bucket = sport === "foot" ? "football" : sport; const log = TICK_LOG[bucket]; if (log) { log.push(Date.now()); const cutoff = Date.now() - TICK_WINDOW_MS; while (log.length && log[0] < cutoff) log.shift(); } } /* Stats accessor consumed by QStatsRibbon. Prunes entries older than TICK_WINDOW_MS at read time so the moving window stays accurate without a separate timer. */ window.MOCK.getStats = function(sport) { const log = TICK_LOG[sport]; if (!log) return { ticksPerMin: 0, lastTickAgeS: null }; const now = Date.now(); const cutoff = now - TICK_WINDOW_MS; while (log.length && log[0] < cutoff) log.shift(); const lastTickAt = log.length ? log[log.length - 1] : null; return { ticksPerMin: log.length, lastTickAgeS: lastTickAt != null ? (now - lastTickAt) / 1000 : null, }; }; function getHistory(sport, key, side, source) { const k = `${sport}|${key}|${side}|${source}`; const arr = HISTORY.get(k); if (!arr) return null; // Return a copy so React doesn't see mutation cycles return arr.slice(); } /* ────────────────────────────────────────────────────────── Tennis score parser Backend ships strings like "6-4 7-5", "6-3 2-1 (15-30)", "1-0". We strip the (current game) part, split on whitespace, then pull the per-set numbers as strings to keep the scoreboard rendering simple. ────────────────────────────────────────────────────────── */ function parseTennisScore(scoreStr) { if (!scoreStr) return { p1: ["0"], p2: ["0"], serving: null }; const cleaned = String(scoreStr).replace(/\([^)]*\)/g, "").trim(); const tokens = cleaned.split(/\s+/).filter(Boolean); const p1 = [], p2 = []; for (const t of tokens) { const m = t.match(/^(\d+)[-:](\d+)/); if (!m) continue; p1.push(m[1]); p2.push(m[2]); } if (p1.length === 0) { p1.push("0"); p2.push("0"); } return { p1, p2, serving: null }; } /* ────────────────────────────────────────────────────────── Best-edge helper Returns { edge, bm, side } across all bookmaker entries where side is "p1" or "p2" (QUANTA's 2-way labelling). Guarantees `bm` is a real key present in the passed dict; callers must pre-filter empty dicts (returns null then). ────────────────────────────────────────────────────────── */ function computeBestEdge(bm) { const keys = Object.keys(bm); if (!keys.length) return null; let best = { edge: -Infinity, bm: null, side: "p1" }; for (const [name, b] of Object.entries(bm)) { if (b.e1 != null && b.e1 > best.edge) best = { edge: b.e1, bm: name, side: "p1" }; if (b.e2 != null && b.e2 > best.edge) best = { edge: b.e2, bm: name, side: "p2" }; // Draw outcome only exists for football (3-way market). Tennis dicts // never carry `eDraw` so this branch is naturally a no-op. if (b.eDraw != null && b.eDraw > best.edge) best = { edge: b.eDraw, bm: name, side: "draw" }; } if (best.bm == null) { // No positive edges anywhere — pick the first BM as the placeholder so // downstream renders that index `m.bm[bestEdgeBm]` don't crash. return { edge: 0, bm: keys[0], side: "p1" }; } return best; } /* Build a Betfair-shaped object straight from the upstream payload, with null preserved when the LAY isn't available. We deliberately do NOT synthesise a baseline from BM medians: a fake LAY would compute edges ≈ 0% against the very BMs it averaged and silently bury real missing-reference cases. Downstream components MUST handle null. */ function rawBfBaseline(bfRaw, sideFields) { return { p1: bfRaw?.[sideFields.p1] ?? null, p2: bfRaw?.[sideFields.p2] ?? null, p1_lay_size: bfRaw?.[sideFields.p1_size] ?? null, p2_lay_size: bfRaw?.[sideFields.p2_size] ?? null, // Per-runner total matched (GBP) — depth on each side // independently of the at-best-price liquidity. The // server stores p{1,2}_matched / home_matched / away_matched // depending on sport; the caller supplies the right key // names via sideFields. p1_matched: sideFields.p1_matched ? (bfRaw?.[sideFields.p1_matched] ?? null) : null, p2_matched: sideFields.p2_matched ? (bfRaw?.[sideFields.p2_matched] ?? null) : null, // Volume matched AT the current at-best LAY price specifically. // More granular than the runner total: tells the user "how much // has been traded at this exact price right now". Goes stale on // every price move (the server doesn't carry it across ticks). p1_lay_matched: sideFields.p1_lay_matched ? (bfRaw?.[sideFields.p1_lay_matched] ?? null) : null, p2_lay_matched: sideFields.p2_lay_matched ? (bfRaw?.[sideFields.p2_lay_matched] ?? null) : null, last_seen: bfRaw?.last_seen, url: bfRaw?.url || "", // Market-level total matched (GBP). Stays null until Betfair's // stream has emitted at least one `tv` delta for this market. total_matched: bfRaw?.total_matched ?? null, }; } /* ────────────────────────────────────────────────────────── Tennis match adapter ────────────────────────────────────────────────────────── */ function adaptTennisMatch(raw) { const key = raw.key; const p1 = raw.player1 || raw.p1 || "—"; const p2 = raw.player2 || raw.p2 || "—"; const tournament = raw.tournament || raw.tour || "Tennis"; const surface = raw.surface || "—"; const score = parseTennisScore(raw.score); const decided = !!raw.decided; // Bookmakers → 2-way odds + edges const bm = {}; const rawBms = raw.bookmakers || {}; for (const [name, src] of Object.entries(rawBms)) { const p1Odds = src.p1_odds; const p2Odds = src.p2_odds; if (p1Odds == null && p2Odds == null) continue; bm[name] = { p1: p1Odds != null ? +p1Odds : null, p2: p2Odds != null ? +p2Odds : null, e1: src.edges?.p1 ?? null, e2: src.edges?.p2 ?? null, last_seen: src.last_seen, url: src.url || "", }; pushHistory("tennis", key, "p1", name, p1Odds); pushHistory("tennis", key, "p2", name, p2Odds); } // A match with zero bookmaker quotes is useless to QUANTA's comparison // dashboard — drop it (returning null signals the caller to filter). if (Object.keys(bm).length === 0) return null; // Betfair LAY baseline — null when the real BF stream hasn't matched // this fixture. Components must render "—" in that case rather than // see a fake median-of-BMs that hides the absence of a reference. const bf = rawBfBaseline(raw.betfair, { p1: "p1_lay", p2: "p2_lay", p1_size: "p1_lay_size", p2_size: "p2_lay_size", p1_matched: "p1_matched", p2_matched: "p2_matched", p1_lay_matched: "p1_lay_matched", p2_lay_matched: "p2_lay_matched", }); pushHistory("tennis", key, "p1", "Betfair", bf.p1); pushHistory("tennis", key, "p2", "Betfair", bf.p2); // Polymarket reference (informational) const pm = raw.polymarket && Object.keys(raw.polymarket).length ? { p1Buy: raw.polymarket.p1_buy ?? raw.polymarket.p1_lay ?? null, p1Sell: raw.polymarket.p1_sell ?? raw.polymarket.p1_back ?? null, p2Buy: raw.polymarket.p2_buy ?? raw.polymarket.p2_lay ?? null, p2Sell: raw.polymarket.p2_sell ?? raw.polymarket.p2_back ?? null, url: raw.polymarket.url || "", last_seen: raw.polymarket.last_seen, } : null; const best = computeBestEdge(bm); // History payload used by QEvolutionChart const history = { p1: {}, p2: {} }; history.p1.Betfair = getHistory("tennis", key, "p1", "Betfair") || [bf.p1 || 2.0]; history.p2.Betfair = getHistory("tennis", key, "p2", "Betfair") || [bf.p2 || 2.0]; for (const name of Object.keys(bm)) { history.p1[name] = getHistory("tennis", key, "p1", name) || [bm[name].p1 || 2.0]; history.p2[name] = getHistory("tennis", key, "p2", name) || [bm[name].p2 || 2.0]; } // Minimal events list — we don't have a real event feed; surface the // best-edge appearance as a single "edge" event so the panel isn't empty. const events = []; if (best.edge > 0 && bm[best.bm]) { events.push({ ts: (raw.last_update || Date.now() / 1000), kind: "edge", bm: best.bm, side: best.side, odds: bm[best.bm][best.side], edge: best.edge, text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1.split(",")[0] : p2.split(",")[0]} (edge ${(best.edge * 100).toFixed(1)}%)`, }); } return { key, sport: "tennis", tournament, surface, p1, p2, score, minute: null, decided, bf, bm, pm, bestEdge: best.edge > 0 ? best.edge : 0, bestEdgeOn: best.side, bestEdgeBm: best.bm, history, events, last_update: raw.last_update, }; } /* ────────────────────────────────────────────────────────── Football match adapter — 2-way representation (home/away) The draw outcome is preserved on bf.draw for the detail comparison table; the cards/grid show home vs away only. ────────────────────────────────────────────────────────── */ function adaptFootballMatch(raw) { const key = raw.key; const p1 = raw.home_team || raw.p1 || "—"; const p2 = raw.away_team || raw.p2 || "—"; const tournament = raw.tournament || "Football"; const decided = !!raw.decided; const scoreLine = `${raw.home_goals ?? 0} - ${raw.away_goals ?? 0}`; let minute = ""; if (raw.minute != null && raw.minute !== "") minute = `${raw.minute}'`; if (raw.period && raw.period !== "1H") { minute = minute ? `${minute} ${raw.period}` : raw.period; } if (!minute) minute = raw.period || "LIVE"; const bm = {}; const rawBms = raw.bookmakers || {}; for (const [name, src] of Object.entries(rawBms)) { const homeOdds = src.home_odds; const awayOdds = src.away_odds; const drawOdds = src.draw_odds; if (homeOdds == null && awayOdds == null && drawOdds == null) continue; bm[name] = { p1: homeOdds != null ? +homeOdds : null, p2: awayOdds != null ? +awayOdds : null, draw: drawOdds != null ? +drawOdds : null, e1: src.edges?.home ?? null, e2: src.edges?.away ?? null, eDraw: src.edges?.draw ?? null, last_seen: src.last_seen, url: src.url || "", }; pushHistory("foot", key, "p1", name, homeOdds); pushHistory("foot", key, "p2", name, awayOdds); } if (Object.keys(bm).length === 0) return null; const bf = rawBfBaseline(raw.betfair, { p1: "home_lay", p2: "away_lay", p1_size: "home_lay_size", p2_size: "away_lay_size", p1_matched: "home_matched", p2_matched: "away_matched", p1_lay_matched: "home_lay_matched", p2_lay_matched: "away_lay_matched", }); bf.draw = raw.betfair?.draw_lay ?? null; bf.draw_lay_size = raw.betfair?.draw_lay_size ?? null; bf.draw_matched = raw.betfair?.draw_matched ?? null; bf.draw_lay_matched = raw.betfair?.draw_lay_matched ?? null; pushHistory("foot", key, "p1", "Betfair", bf.p1); pushHistory("foot", key, "p2", "Betfair", bf.p2); const best = computeBestEdge(bm); const history = { p1: {}, p2: {} }; history.p1.Betfair = getHistory("foot", key, "p1", "Betfair") || [bf.p1 || 2.0]; history.p2.Betfair = getHistory("foot", key, "p2", "Betfair") || [bf.p2 || 2.0]; for (const name of Object.keys(bm)) { history.p1[name] = getHistory("foot", key, "p1", name) || [bm[name].p1 || 2.0]; history.p2[name] = getHistory("foot", key, "p2", name) || [bm[name].p2 || 2.0]; } const events = []; if (best.edge > 0 && bm[best.bm]) { events.push({ ts: (raw.last_update || Date.now() / 1000), kind: "edge", bm: best.bm, side: best.side, odds: bm[best.bm][best.side], edge: best.edge, text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1 : p2} (edge ${(best.edge * 100).toFixed(1)}%)`, }); } return { key, sport: "football", tournament, p1, p2, scoreLine, minute, home_goals: raw.home_goals, away_goals: raw.away_goals, period: raw.period, decided, bf, bm, pm: null, bestEdge: best.edge > 0 ? best.edge : 0, bestEdgeOn: best.side, bestEdgeBm: best.bm, score: { p1: [String(raw.home_goals ?? 0)], p2: [String(raw.away_goals ?? 0)] }, history, events, last_update: raw.last_update, }; } /* ────────────────────────────────────────────────────────── Stable display order ───────────────────── If we re-sorted by bestEdge on every snapshot, cards would jump around the screen every time an odds tick moved an edge by 0.1% — terrible UX when the user is about to click a card. Instead we keep a per-sport ordered key list: • existing matches keep their slot, no matter how their edge moves • dropped matches (no longer in the snapshot) are removed • new matches are inserted into the list at the position dictated by their current bestEdge The TOP_OPS leaderboard at the top of the page is computed fresh on every tick (it's a live leaderboard, jumping is expected there). ────────────────────────────────────────────────────────── */ const STABLE_ORDER = { tennis: [], football: [], baseball: [] }; function stabilizeOrder(sport, adapted) { const matchByKey = new Map(adapted.map(m => [m.key, m])); const live = new Set(matchByKey.keys()); // 1. Existing order, minus matches that are gone const kept = STABLE_ORDER[sport].filter(k => live.has(k)); const known = new Set(kept); // 2. New matches (first time we see this key) const newMatches = adapted.filter(m => !known.has(m.key)); if (newMatches.length === 0) { STABLE_ORDER[sport] = kept; return kept.map(k => matchByKey.get(k)); } // 3. Insert each new match in the position its current bestEdge dictates newMatches.sort((a, b) => b.bestEdge - a.bestEdge); const ordered = [...kept]; for (const m of newMatches) { let insertAt = ordered.length; for (let i = 0; i < ordered.length; i++) { const existing = matchByKey.get(ordered[i]); if (!existing) continue; if (m.bestEdge > existing.bestEdge) { insertAt = i; break; } } ordered.splice(insertAt, 0, m.key); } STABLE_ORDER[sport] = ordered; return ordered.map(k => matchByKey.get(k)); } /* ────────────────────────────────────────────────────────── Baseball match adapter — 2-way (home/away). MLB regular-season ties are rare enough that bookmakers post "Vainqueur Match" as a 2-way market; no draw column. The raw `score` from the baseball server is a short string the bookmakers emit verbatim (e.g. "T5 0-2"); we surface it on `scoreLine` so the cards/detail view can render it straight. ────────────────────────────────────────────────────────── */ function adaptBaseballMatch(raw) { const key = raw.key; const p1 = raw.home_team || raw.p1 || "—"; const p2 = raw.away_team || raw.p2 || "—"; const tournament = raw.tournament || "Baseball"; const decided = !!raw.decided; const scoreLine = raw.score || ""; const minute = raw.inning || "LIVE"; const bm = {}; const rawBms = raw.bookmakers || {}; for (const [name, src] of Object.entries(rawBms)) { const homeOdds = src.home_odds; const awayOdds = src.away_odds; if (homeOdds == null && awayOdds == null) continue; bm[name] = { p1: homeOdds != null ? +homeOdds : null, p2: awayOdds != null ? +awayOdds : null, e1: src.edges?.home ?? null, e2: src.edges?.away ?? null, last_seen: src.last_seen, url: src.url || "", }; pushHistory("baseball", key, "p1", name, homeOdds); pushHistory("baseball", key, "p2", name, awayOdds); } if (Object.keys(bm).length === 0) return null; const bf = rawBfBaseline(raw.betfair, { p1: "home_lay", p2: "away_lay", p1_size: "home_lay_size", p2_size: "away_lay_size", p1_matched: "home_matched", p2_matched: "away_matched", p1_lay_matched: "home_lay_matched", p2_lay_matched: "away_lay_matched", }); pushHistory("baseball", key, "p1", "Betfair", bf.p1); pushHistory("baseball", key, "p2", "Betfair", bf.p2); const best = computeBestEdge(bm); const history = { p1: {}, p2: {} }; history.p1.Betfair = getHistory("baseball", key, "p1", "Betfair") || [bf.p1 || 2.0]; history.p2.Betfair = getHistory("baseball", key, "p2", "Betfair") || [bf.p2 || 2.0]; for (const name of Object.keys(bm)) { history.p1[name] = getHistory("baseball", key, "p1", name) || [bm[name].p1 || 2.0]; history.p2[name] = getHistory("baseball", key, "p2", name) || [bm[name].p2 || 2.0]; } const events = []; if (best.edge > 0 && bm[best.bm]) { events.push({ ts: (raw.last_update || Date.now() / 1000), kind: "edge", bm: best.bm, side: best.side, odds: bm[best.bm][best.side], edge: best.edge, text: `${best.bm} → ${bm[best.bm][best.side]?.toFixed(2)} sur ${best.side === "p1" ? p1 : p2} (edge ${(best.edge * 100).toFixed(1)}%)`, }); } return { key, sport: "baseball", tournament, p1, p2, scoreLine, minute, decided, bf, bm, pm: null, bestEdge: best.edge > 0 ? best.edge : 0, bestEdgeOn: best.side, bestEdgeBm: best.bm, score: { p1: [scoreLine], p2: [] }, history, events, last_update: raw.last_update, }; } /* ────────────────────────────────────────────────────────── Snapshot ingest — mutates window.MOCK in place ────────────────────────────────────────────────────────── */ function ingest(sport, snapshotData) { const matches = snapshotData?.matches || []; const adapter = sport === "tennis" ? adaptTennisMatch : sport === "baseball" ? adaptBaseballMatch : adaptFootballMatch; const adapted = matches .map(m => { try { return adapter(m); } catch (e) { /* drop bad match silently */ return null; } }) .filter(Boolean); // Apply stable order so existing cards don't jump around on every tick const stable = stabilizeOrder(sport, adapted); if (sport === "tennis") window.MOCK.TENNIS_MATCHES = stable; else if (sport === "baseball") window.MOCK.BASEBALL_MATCHES = stable; else window.MOCK.FOOT_MATCHES = stable; // Expose the most recent raw snapshot so QUANTA can consume server-side // event payloads (e.g. snapshotData.recent_events for edge alerts). This // is read once-per-tick by the alerts listener in quanta-app.jsx; we keep // only the latest snapshot per sport on a shared object to avoid mixing // tennis events into football pushes. if (!window.MOCK._lastSnapshotBySport) { window.MOCK._lastSnapshotBySport = {}; } window.MOCK._lastSnapshotBySport[sport] = snapshotData; window.MOCK._lastSnapshot = snapshotData; window.MOCK.ALL_MATCHES = [ ...window.MOCK.TENNIS_MATCHES, ...window.MOCK.FOOT_MATCHES, ...window.MOCK.BASEBALL_MATCHES, ]; // TOP_OPS is the live leaderboard — it MUST re-rank on every tick // (that's the whole point of "top 3 opportunities right now"). It's // a 3-card strip at the top, not the main scrollable list, so the // re-ranking doesn't disturb scrolling. window.MOCK.TOP_OPS = [...window.MOCK.ALL_MATCHES] .sort((a, b) => b.bestEdge - a.bestEdge) .slice(0, 3); // Tell React to re-render window.dispatchEvent(new CustomEvent("quanta-data-update", { detail: { sport } })); } /* ────────────────────────────────────────────────────────── WebSocket client with exponential backoff reconnect ────────────────────────────────────────────────────────── */ function wsUrl(path) { const proto = location.protocol === "https:" ? "wss:" : "ws:"; return `${proto}//${location.host}${path}`; } function connectStream(sport, path) { let retry = 0; let ws = null; let alive = false; const open = () => { const url = wsUrl(path); try { ws = new WebSocket(url); } catch (e) { // ws constructor threw — scheduler will retry return schedule(); } ws.onopen = () => { retry = 0; alive = true; // ws OPEN — status pill in header tracks this setStreamStatus(sport, "live"); }; ws.onclose = (ev) => { alive = false; setStreamStatus(sport, "down"); if (ev && ev.code === 1008) { window.dispatchEvent(new CustomEvent("quanta-auth-lost")); return; } schedule(); }; ws.onerror = (e) => { // ws error — onclose will follow and trigger reconnect }; ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg && msg.type === "snapshot" && msg.data) { ingest(sport, msg.data); } } catch (e) { // malformed ws frame — skip silently } }; }; const schedule = () => { retry = Math.min(12, retry + 1); const delay = Math.min(15000, 500 * retry); setTimeout(open, delay); }; open(); // Fallback REST poll when WS is dead — keeps the dashboard fresh // even during a brief outage. We hit /api/matches (and /football/api/matches) // and synthesise a snapshot envelope for ingest(). const restTimer = setInterval(async () => { if (alive) return; const restPath = path.replace(/\/ws$/, "/api/matches"); try { const r = await fetch(restPath, { credentials: "include" }); if (r.status === 401) { // Session révoquée — notifier QUANTA pour repasser au login window.dispatchEvent(new CustomEvent("quanta-auth-lost")); return; } if (!r.ok) return; const data = await r.json(); ingest(sport, data); setStreamStatus(sport, "rest"); } catch (e) { /* swallow */ } }, 5000); return { close: () => { try { ws && ws.close(); } catch {} clearInterval(restTimer); } }; } /* Stream status exposed on window.MOCK so the header can render a pill per stream ("LIVE / REST / DOWN"). */ window.MOCK.STREAM = { tennis: "connecting", football: "connecting", baseball: "connecting" }; function setStreamStatus(sport, status) { if (window.MOCK.STREAM[sport] === status) return; window.MOCK.STREAM[sport] = status; window.dispatchEvent(new CustomEvent("quanta-data-update", { detail: { sport, status } })); } /* ────────────────────────────────────────────────────────── Boot — connect both streams. Tennis lives at root, football is mounted at /football on the same origin. ────────────────────────────────────────────────────────── */ function boot() { connectStream("tennis", "/ws"); connectStream("football", "/football/ws"); connectStream("baseball", "/baseball/ws"); } if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", boot); } else { boot(); } // Diagnostic handle for the console // Diagnostic handle (`window.QuantaLiveData`) intentionally removed. // Exposing `ingest` / `HISTORY` to the page lets a malicious browser // extension dump or forge snapshots from one line of JS. Re-enable // ad-hoc with a localStorage flag if you need it for dev. })();