Spaces:
Running
Running
| ; | |
| const $ = (id) => document.getElementById(id); | |
| const REFRESH_INTERVAL = 60_000; | |
| const REQUEST_TIMEOUT = 15_000; | |
| const ICONS = Object.freeze({ | |
| alert: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4 3 20h18L12 4Z"/><path d="M12 10v4m0 3h.01"/></svg>`, | |
| copy: `<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="8" y="8" width="11" height="11" rx="2"/><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"/></svg>`, | |
| shield: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v5.7c0 4.7 3.2 8.3 8 9.8 4.8-1.5 8-5.1 8-9.8V6l-8-3Z"/><path d="m8.8 12 2.1 2.1 4.5-4.6"/></svg>`, | |
| balance: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v18M5 6h14M7 6l-4 8h8L7 6Zm10 0-4 8h8l-4-8ZM7 21h10"/></svg>`, | |
| gift: `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 10h16v10H4zM3 6h18v4H3zM12 6v14M12 6H8.5A2.5 2.5 0 1 1 11 3.5L12 6Zm0 0h3.5A2.5 2.5 0 1 0 13 3.5L12 6Z"/></svg>` | |
| }); | |
| const TICKET_PROFILES = Object.freeze({ | |
| safe: { | |
| title: "SAFE", | |
| subtitle: "Exposição controlada", | |
| risk: "RISCO MENOR", | |
| icon: ICONS.shield | |
| }, | |
| balanced: { | |
| title: "BALANCEADO", | |
| subtitle: "Risco e retorno ajustados", | |
| risk: "RISCO MÉDIO", | |
| icon: ICONS.balance | |
| }, | |
| freebet: { | |
| title: "FREEBET", | |
| subtitle: "Retorno potencial elevado", | |
| risk: "RISCO MAIOR", | |
| icon: ICONS.gift | |
| } | |
| }); | |
| const safe = (value) => String(value ?? "").replace(/[&<>'"]/g, (char) => ({ | |
| "&": "&", | |
| "<": "<", | |
| ">": ">", | |
| "'": "'", | |
| '"': """ | |
| })[char]); | |
| const finite = (value) => { | |
| if (value === null || value === undefined || value === "") return null; | |
| const parsed = Number(value); | |
| return Number.isFinite(parsed) ? parsed : null; | |
| }; | |
| const num = (value, digits = 1) => { | |
| const parsed = finite(value); | |
| return parsed === null ? "—" : parsed.toLocaleString("pt-BR", { | |
| minimumFractionDigits: digits, | |
| maximumFractionDigits: digits | |
| }); | |
| }; | |
| const pct = (value) => { | |
| const parsed = finite(value); | |
| return parsed === null ? "—" : (parsed * 100).toLocaleString("pt-BR", { | |
| minimumFractionDigits: 1, | |
| maximumFractionDigits: 1 | |
| }) + "%"; | |
| }; | |
| const integer = (value) => { | |
| const parsed = finite(value); | |
| return parsed === null ? "—" : Math.round(parsed).toLocaleString("pt-BR"); | |
| }; | |
| function localDate(value) { | |
| if (!value) return "—"; | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) return String(value); | |
| return date.toLocaleString("pt-BR", { | |
| day: "2-digit", | |
| month: "2-digit", | |
| year: "numeric", | |
| hour: "2-digit", | |
| minute: "2-digit" | |
| }); | |
| } | |
| function teamInitials(name) { | |
| const words = String(name || "?").trim().split(/\s+/).filter(Boolean); | |
| const initials = words.length > 1 ? words[0][0] + words[words.length - 1][0] : words[0]?.slice(0, 2); | |
| return String(initials || "?").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toUpperCase(); | |
| } | |
| function boundedScore(value) { | |
| const parsed = finite(value); | |
| return parsed === null ? 0 : Math.max(0, Math.min(100, parsed)); | |
| } | |
| function setStatus(status) { | |
| const pill = $("system-pill"); | |
| const statuses = { | |
| ok: { tone: "ok", pill: "SISTEMA ONLINE", engine: "MOTOR DE PRECISÃO ONLINE", console: "ONLINE", flow: "" }, | |
| degraded: { tone: "warning", pill: "OPERAÇÃO PARCIAL", engine: "MOTOR ONLINE COM ALERTAS", console: "ATENÇÃO", flow: "paused" }, | |
| scanning: { tone: "ok", pill: "ANALISANDO", engine: "VARREDURA EM ANDAMENTO", console: "PROCESSANDO", flow: "" }, | |
| waiting: { tone: "neutral", pill: "AGUARDANDO", engine: "AGUARDANDO PRIMEIRO SCAN", console: "STANDBY", flow: "paused" }, | |
| interrupted: { tone: "bad", pill: "INTERROMPIDO", engine: "VARREDURA INTERROMPIDA", console: "ATENÇÃO", flow: "failed" }, | |
| configuration_error: { tone: "bad", pill: "CONFIGURAÇÃO", engine: "CONFIGURAÇÃO NECESSÁRIA", console: "ATENÇÃO", flow: "failed" }, | |
| error: { tone: "bad", pill: "INDISPONÍVEL", engine: "MOTOR INDISPONÍVEL", console: "ERRO", flow: "failed" } | |
| }; | |
| const current = statuses[status] || { | |
| tone: "neutral", | |
| pill: String(status || "AGUARDANDO").toUpperCase(), | |
| engine: "MOTOR DE PRECISÃO EM ESPERA", | |
| console: "STANDBY", | |
| flow: "paused" | |
| }; | |
| pill.className = `pill ${current.tone}`; | |
| pill.innerHTML = `<span class="status-dot" aria-hidden="true"></span><span>${safe(current.pill)}</span>`; | |
| const engineLabel = $("engine-label"); | |
| const engineState = $("engine-state"); | |
| const engineFlow = $("engine-flow"); | |
| const stateClass = current.tone === "bad" | |
| ? "danger" | |
| : current.tone === "neutral" || current.tone === "warning" | |
| ? "paused" | |
| : ""; | |
| if (engineLabel) { | |
| engineLabel.className = `live-label ${stateClass}`.trim(); | |
| engineLabel.innerHTML = `<span aria-hidden="true"></span><b>${safe(current.engine)}</b>`; | |
| } | |
| if (engineState) { | |
| engineState.className = `console-state ${stateClass}`.trim(); | |
| engineState.innerHTML = `<i aria-hidden="true"></i><b>${safe(current.console)}</b>`; | |
| } | |
| if (engineFlow) engineFlow.className = `engine-flow ${current.flow}`.trim(); | |
| } | |
| function warnings(items) { | |
| const values = Array.isArray(items) ? items.filter((item) => item !== null && item !== undefined && item !== "") : []; | |
| $("warnings").innerHTML = values.map((item) => `<div class="warning">${ICONS.alert}<span>${safe(item)}</span></div>`).join(""); | |
| } | |
| function modelRow(label, value) { | |
| return `<div class="model-row"><span>${label}</span><b>${value}</b></div>`; | |
| } | |
| function pickCard(pick, index) { | |
| const p = pick && typeof pick === "object" ? pick : {}; | |
| const detail = p.model_detail && typeof p.model_detail === "object" ? p.model_detail : {}; | |
| const score = boundedScore(p.safe_score); | |
| const reasons = Array.isArray(p.reasons) | |
| ? p.reasons.map((reason) => `<span class="tag">${safe(reason)}</span>`).join("") | |
| : ""; | |
| const oddInputId = `market-odd-${index}`; | |
| const selection = p.selection ? `${p.selection} vence` : "Seleção indisponível"; | |
| const probability = finite(p.probability); | |
| const modelRows = [ | |
| modelRow("Dixon-Coles / Poisson", pct(detail.poisson)), | |
| modelRow("Elo", pct(detail.elo)), | |
| modelRow("Forma ponderada", pct(detail.form)), | |
| modelRow("Modelo interno", pct(p.raw_model_probability)), | |
| modelRow("Mercado sem margem", pct(p.market_probability)), | |
| modelRow("Peso do modelo interno", pct(detail.internal_weight)), | |
| modelRow("Pesos Poisson / Elo / Forma", `${pct(detail.weight_poisson)} / ${pct(detail.weight_elo)} / ${pct(detail.weight_form)}`), | |
| modelRow("Amostra walk-forward / holdout", `${integer(detail.tuning_total_samples)} / ${integer(detail.tuning_samples)}`), | |
| modelRow("Brier Skill vs. climatologia", detail.tuning_brier_skill == null ? "—" : pct(detail.tuning_brier_skill)), | |
| modelRow("Validação do modelo", detail.model_validation == null ? "—" : pct(detail.model_validation)), | |
| modelRow("Piso Poisson / Elo", detail.core_model_floor == null ? "—" : pct(detail.core_model_floor)), | |
| modelRow("Ganho Brier tuning", detail.tuning_gain == null ? "—" : num(detail.tuning_gain, 4)), | |
| modelRow("Qualidade dos dados", pct(p.quality)), | |
| modelRow("Confiabilidade composta", pct(p.reliability)), | |
| modelRow("Casas no consenso", integer(p.market_bookmakers)), | |
| modelRow("Dispersão do mercado", pct(p.market_dispersion)), | |
| modelRow("Confiança no nome do time", pct(p.name_confidence)), | |
| modelRow("Calibração forward", p.calibration_delta == null ? "—" : `${num(Number(p.calibration_delta) * 100, 1)} pp`), | |
| modelRow("Edge vs. mercado", pct(p.edge)) | |
| ].join(""); | |
| return `<article class="pick panel" aria-label="${safe(p.home)} contra ${safe(p.away)}"> | |
| <div class="pick-head"> | |
| <div class="pick-meta"> | |
| <span class="rank">#${String(index + 1).padStart(2, "0")} · ${safe(p.label || "SAFE")}</span> | |
| <span class="competition-chip">${safe(p.competition || "Competição")}</span> | |
| <span class="kickoff">Início · ${safe(localDate(p.kickoff))}</span> | |
| </div> | |
| <div class="score-ring" role="img" aria-label="Score de confiança ${num(score, 1)} de 100"> | |
| <svg viewBox="0 0 44 44" aria-hidden="true"><circle class="score-track" cx="22" cy="22" r="19" pathLength="100"/><circle class="score-value" cx="22" cy="22" r="19" pathLength="100" stroke-dasharray="${score} 100"/></svg> | |
| <span><strong>${num(p.safe_score, 1)}</strong><small>SCORE</small></span> | |
| </div> | |
| </div> | |
| <div class="pick-body"> | |
| <div class="matchup"> | |
| <div class="team"><span class="team-badge" aria-hidden="true">${safe(teamInitials(p.home))}</span><strong>${safe(p.home || "Mandante")}</strong></div> | |
| <span class="versus">VS</span> | |
| <div class="team"><span class="team-badge" aria-hidden="true">${safe(teamInitials(p.away))}</span><strong>${safe(p.away || "Visitante")}</strong></div> | |
| </div> | |
| <div class="selection-box"> | |
| <div><span>SELEÇÃO DO MODELO</span><strong>${safe(selection)}</strong></div> | |
| <span class="selection-signal"><i aria-hidden="true"></i> APROVADA</span> | |
| </div> | |
| <div class="metrics" aria-label="Métricas da seleção"> | |
| <div class="metric"><span>PROBABILIDADE</span><strong>${pct(p.probability)}</strong></div> | |
| <div class="metric"><span>CONSERVADORA</span><strong>${pct(p.conservative_probability)}</strong></div> | |
| <div class="metric"><span>ODD MERCADO</span><strong>${num(p.odd, 2)}</strong></div> | |
| <div class="metric"><span>ODD JUSTA</span><strong>${num(p.fair_odd, 2)}</strong></div> | |
| </div> | |
| <div class="confidence-line"> | |
| <span>FORÇA DO SINAL</span> | |
| <progress class="bar" aria-label="Força do sinal" value="${score}" max="100">${num(score, 1)}%</progress> | |
| <span>${num(score, 1)}/100</span> | |
| </div> | |
| ${reasons ? `<div class="reasons">${reasons}</div>` : ""} | |
| <details class="models-detail"> | |
| <summary>Auditoria de modelos e qualidade</summary> | |
| <div class="model-table">${modelRows}</div> | |
| </details> | |
| <div class="pick-tools"> | |
| <div class="copyline"> | |
| <button class="copybtn" type="button" data-copy="${safe(p.selection)}" aria-label="Copiar seleção ${safe(p.selection)}">${ICONS.copy} Copiar seleção</button> | |
| <button class="copybtn" type="button" data-copy="${safe(p.home)} x ${safe(p.away)}" aria-label="Copiar nome do jogo">${ICONS.copy} Copiar jogo</button> | |
| </div> | |
| </div> | |
| <div class="oddbox"> | |
| <div class="odd-input-row"> | |
| <label for="${oddInputId}">COMPARE A ODD ENCONTRADA NA SUA CASA</label> | |
| <div class="odd-input-wrap"><input id="${oddInputId}" class="market-odd-input" type="number" inputmode="decimal" min="1.01" step="0.01" placeholder="1,31" data-probability="${probability ?? ""}"></div> | |
| </div> | |
| <span class="oddresult" aria-live="polite">Digite uma odd para estimar o valor esperado.</span> | |
| </div> | |
| </div> | |
| </article>`; | |
| } | |
| function radarSignalValues(value) { | |
| const entries = Array.isArray(value) ? value : value == null ? [] : [value]; | |
| return entries.map((entry) => { | |
| if (typeof entry === "string" || typeof entry === "number") return String(entry).trim(); | |
| if (!entry || typeof entry !== "object") return ""; | |
| return String(entry.message ?? entry.reason ?? entry.label ?? entry.code ?? "").trim(); | |
| }).filter(Boolean).slice(0, 6); | |
| } | |
| function radarTags(items, className = "") { | |
| return items.map((item) => `<span class="radar-tag ${className}">${safe(item)}</span>`).join(""); | |
| } | |
| function radarCard(item, index) { | |
| const p = item && typeof item === "object" ? item : {}; | |
| const blockers = radarSignalValues(p.blockers); | |
| const reasons = radarSignalValues(p.reasons); | |
| const scoreValue = finite(p.safe_score); | |
| const score = scoreValue === null ? null : Math.max(0, Math.min(100, scoreValue)); | |
| const selection = p.selection ? `${p.selection} vence` : "Seleção em análise"; | |
| const blockerMarkup = blockers.length | |
| ? radarTags(blockers, "blocker") | |
| : `<span class="radar-tag blocker">Critérios finais do Risk Gate não atendidos</span>`; | |
| const reasonGroup = reasons.length | |
| ? `<div class="radar-signal-group"><span>SINAIS OBSERVADOS</span><div class="radar-tags">${radarTags(reasons)}</div></div>` | |
| : ""; | |
| return `<article class="radar-card panel" aria-label="Palpite em observação não aprovado: ${safe(p.home)} contra ${safe(p.away)}"> | |
| <div class="radar-card-head"> | |
| <div class="radar-meta"> | |
| <span class="radar-index">WATCH ${String(index + 1).padStart(2, "0")}</span> | |
| <span class="radar-competition">${safe(p.competition || "Competição")}</span> | |
| <span class="radar-kickoff">Início · ${safe(localDate(p.kickoff))}</span> | |
| </div> | |
| <div class="radar-score" role="img" aria-label="Score observado ${score === null ? "indisponível" : `${num(score, 1)} de 100`}"> | |
| <strong>${score === null ? "—" : num(score, 1)}</strong><small>SCORE</small> | |
| </div> | |
| </div> | |
| <div class="radar-matchup"> | |
| <div class="radar-team"><span class="radar-team-badge" aria-hidden="true">${safe(teamInitials(p.home))}</span><strong>${safe(p.home || "Mandante")}</strong></div> | |
| <span class="radar-versus">VS</span> | |
| <div class="radar-team"><span class="radar-team-badge" aria-hidden="true">${safe(teamInitials(p.away))}</span><strong>${safe(p.away || "Visitante")}</strong></div> | |
| </div> | |
| <div class="radar-selection"> | |
| <div class="radar-selection-copy"> | |
| <div><span>LEITURA EM ANÁLISE</span><strong>${safe(selection)}</strong></div> | |
| <span class="radar-eye" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M3 12s3.2-6 9-6 9 6 9 6-3.2 6-9 6-9-6-9-6Z"/><circle cx="12" cy="12" r="2.5"/></svg></span> | |
| </div> | |
| <div class="observation-label"><i aria-hidden="true"></i> EM OBSERVAÇÃO — NÃO APROVADA</div> | |
| </div> | |
| <div class="radar-metrics" aria-label="Métricas informativas do palpite em observação"> | |
| <div class="radar-metric"><span>PROBABILIDADE</span><strong>${pct(p.probability)}</strong></div> | |
| <div class="radar-metric"><span>CONSERVADORA</span><strong>${pct(p.conservative_probability)}</strong></div> | |
| <div class="radar-metric"><span>SAFE SCORE</span><strong>${score === null ? "—" : num(score, 1)}</strong></div> | |
| <div class="radar-metric"><span>ODD MERCADO</span><strong>${num(p.odd, 2)}</strong></div> | |
| <div class="radar-metric"><span>ODD JUSTA</span><strong>${num(p.fair_odd, 2)}</strong></div> | |
| <div class="radar-metric model-ev"><span>EV DO MODELO</span><strong>${pct(p.model_ev)}</strong></div> | |
| </div> | |
| <div class="radar-signals"> | |
| <div class="radar-signal-group"><span>POR QUE NÃO PASSOU</span><div class="radar-tags">${blockerMarkup}</div></div> | |
| ${reasonGroup} | |
| </div> | |
| <div class="radar-card-footer"> | |
| <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3 4 6v5.7c0 4.7 3.2 8.3 8 9.8 4.8-1.5 8-5.1 8-9.8V6l-8-3Z"/><path d="m8.7 8.7 6.6 6.6m0-6.6-6.6 6.6"/></svg> | |
| FORA DAS SELEÇÕES SAFE E DOS BILHETES | |
| </div> | |
| </article>`; | |
| } | |
| function renderRadar(state) { | |
| const source = Array.isArray(state.radar) | |
| ? state.radar | |
| : Array.isArray(state.radar?.items) | |
| ? state.radar.items | |
| : []; | |
| const items = source.filter((item) => item && typeof item === "object" && item.approved === false).slice(0, 5); | |
| const container = $("radar-cards"); | |
| if (!container) return; | |
| container.innerHTML = items.length | |
| ? items.map(radarCard).join("") | |
| : `<div class="radar-empty panel empty"><div class="empty-content"><span class="empty-icon"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 12s3.2-6 9-6 9 6 9 6-3.2 6-9 6-9-6-9-6Z"/><circle cx="12" cy="12" r="2.5"/></svg></span><strong>Nenhum palpite em observação</strong><small>O radar está vazio nesta leitura porque nenhum evento chegou à etapa de modelagem.</small></div></div>`; | |
| container.setAttribute("aria-busy", "false"); | |
| } | |
| function emptyTicket(profileName) { | |
| const profile = TICKET_PROFILES[profileName]; | |
| return `<article class="ticket panel" data-profile="${profileName}"> | |
| <div class="ticket-head"> | |
| <div class="ticket-profile"><span class="ticket-icon">${profile.icon}</span><div><h3>${profile.title}</h3><span>${profile.subtitle}</span></div></div> | |
| <span class="risk-chip">${profile.risk}</span> | |
| </div> | |
| <div class="empty"><div class="empty-content"><span class="empty-icon">—</span><strong>Bilhete ainda indisponível</strong><small>São necessárias mais seleções aprovadas para montar este perfil.</small></div></div> | |
| </article>`; | |
| } | |
| function ticketCard(profileName, ticket) { | |
| const profile = TICKET_PROFILES[profileName]; | |
| if (!ticket || typeof ticket !== "object") return emptyTicket(profileName); | |
| const legs = Array.isArray(ticket.legs) ? ticket.legs : []; | |
| const legMarkup = legs.map((leg) => `<div class="ticket-leg"> | |
| <b>${safe(leg?.selection)} vence <span aria-label="com odd">@</span> ${num(leg?.odd, 2)}</b> | |
| <span title="${safe(leg?.match)}">${safe(leg?.match)} · score ${num(leg?.safe_score, 1)}</span> | |
| </div>`).join(""); | |
| const targetMet = ticket.target_met === true; | |
| return `<article class="ticket panel" data-profile="${profileName}"> | |
| <div class="ticket-head"> | |
| <div class="ticket-profile"><span class="ticket-icon">${profile.icon}</span><div><h3>${profile.title}</h3><span>${profile.subtitle}</span></div></div> | |
| <span class="risk-chip">${profile.risk}</span> | |
| </div> | |
| <div class="ticket-odd"><span>ODD COMBINADA</span><strong>@ ${num(ticket.total_odd, 2)}</strong></div> | |
| <div class="ticket-summary"> | |
| <div><span>PROB. EST.</span><strong>${pct(ticket.joint_probability)}</strong></div> | |
| <div><span>STRESS</span><strong>${pct(ticket.joint_conservative)}</strong></div> | |
| <div><span>EV EST.</span><strong>${pct(ticket.estimated_ev)}</strong></div> | |
| </div> | |
| <div class="target-state ${targetMet ? "met" : ""}"><i aria-hidden="true"></i>${targetMet ? "Meta atingida" : "Abaixo da meta"} · alvo @ ${num(ticket.target_odd, 1)}</div> | |
| <div class="ticket-legs">${legMarkup || `<div class="empty">Nenhuma perna disponível.</div>`}</div> | |
| </article>`; | |
| } | |
| function healthCard(label, value, note = "") { | |
| return `<div class="health-card"><span>${label}</span><strong>${value}</strong>${note ? `<small>${note}</small>` : ""}</div>`; | |
| } | |
| function providerValue(isOnline, isDegraded) { | |
| const tone = isOnline ? "online" : isDegraded ? "partial" : ""; | |
| const label = isOnline ? "ONLINE" : isDegraded ? "PARCIAL" : "AGUARDANDO"; | |
| return `<span class="provider-status ${tone}"><i aria-hidden="true"></i>${label}</span>`; | |
| } | |
| function renderHealth(state) { | |
| const quota = state.providers?.odds_api?.quota || {}; | |
| const degraded = state.status === "degraded"; | |
| const cards = [ | |
| healthCard("FOOTBALL DATA", providerValue(Boolean(state.providers?.football_data?.ok), degraded), "dados de jogos"), | |
| healthCard("ODDS API", providerValue(Boolean(state.providers?.odds_api?.ok), degraded), "consenso de mercado"), | |
| healthCard("CRÉDITOS ODDS", safe(integer(quota.remaining)), "consultas restantes"), | |
| healthCard("BASE HISTÓRICA", `${safe(integer(state.summary?.historical_matches))} jogos`, "amostra disponível"), | |
| healthCard("PALPITES LIQUIDADOS", safe(integer(state.performance?.settled)), "forward tracking"), | |
| healthCard("ROI FORWARD", safe(pct(state.performance?.roi)), "retorno observado"), | |
| healthCard("BRIER SCORE", safe(num(state.performance?.brier_score, 3)), "erro probabilístico"), | |
| healthCard("CALIBRAÇÃO ECE", safe(pct(state.performance?.ece)), "desvio de calibração"), | |
| healthCard("VERSÃO DO MODELO", safe(state.model_version || "—"), "motor em execução") | |
| ]; | |
| $("health").innerHTML = cards.join(""); | |
| $("health").setAttribute("aria-busy", "false"); | |
| } | |
| function renderState(payload) { | |
| const state = payload && typeof payload === "object" ? payload : {}; | |
| const picks = Array.isArray(state.picks) ? state.picks : []; | |
| const radarCount = Array.isArray(state.radar) ? state.radar.length : Array.isArray(state.radar?.items) ? state.radar.items.length : 0; | |
| setStatus(state.status || "waiting"); | |
| $("s-events").textContent = integer(state.summary?.events); | |
| $("s-approved").textContent = integer(state.summary?.approved ?? picks.length); | |
| $("s-best").textContent = picks.length ? num(Math.max(...picks.map((item) => boundedScore(item?.safe_score))), 1) : "—"; | |
| $("s-winrate").textContent = state.performance?.win_rate == null ? "—" : pct(state.performance.win_rate); | |
| $("last-update").textContent = `Atualizado · ${localDate(state.generated_at)}`; | |
| warnings(state.warnings); | |
| $("picks").innerHTML = picks.length | |
| ? picks.map(pickCard).join("") | |
| : `<div class="panel empty"><div class="empty-content"><span class="empty-icon">✓</span><strong>Nenhuma aposta SAFE foi liberada</strong><small>O Risk Gate não força entradas. ${radarCount ? `${radarCount} jogo${radarCount === 1 ? '' : 's'} está${radarCount === 1 ? '' : 'ão'} em observação no Radar.` : 'Os times monitorados ficam no Radar, sem recomendação de aposta.'}</small><a class="empty-radar-link" href="#radar">Ver times no Radar <span aria-hidden="true">↓</span></a></div></div>`; | |
| $("picks").setAttribute("aria-busy", "false"); | |
| renderRadar(state); | |
| const tickets = state.tickets && typeof state.tickets === "object" ? state.tickets : {}; | |
| $("tickets").innerHTML = ["safe", "balanced", "freebet"].map((key) => ticketCard(key, tickets[key])).join(""); | |
| $("tickets").setAttribute("aria-busy", "false"); | |
| renderHealth(state); | |
| } | |
| function setRegionsBusy(isBusy) { | |
| ["picks", "radar-cards", "tickets", "health"].forEach((id) => $(id)?.setAttribute("aria-busy", String(isBusy))); | |
| } | |
| let activeController = null; | |
| let toastTimer = null; | |
| function showToast(message) { | |
| const toast = $("toast"); | |
| if (!toast) return; | |
| toast.textContent = message; | |
| toast.classList.add("show"); | |
| clearTimeout(toastTimer); | |
| toastTimer = setTimeout(() => toast.classList.remove("show"), 2300); | |
| } | |
| async function load({ force = false, announce = false } = {}) { | |
| if (activeController && !force) return; | |
| if (activeController) activeController.abort(); | |
| const controller = new AbortController(); | |
| activeController = controller; | |
| const refreshButton = $("refresh-ui"); | |
| setRegionsBusy(true); | |
| refreshButton.classList.add("loading"); | |
| refreshButton.disabled = true; | |
| refreshButton.setAttribute("aria-label", "Atualizando painel"); | |
| const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT); | |
| try { | |
| const response = await fetch("/api/state", { | |
| cache: "no-store", | |
| headers: { Accept: "application/json" }, | |
| signal: controller.signal | |
| }); | |
| if (!response.ok) throw new Error(`servidor respondeu ${response.status}`); | |
| const state = await response.json(); | |
| renderState(state); | |
| if (announce) showToast("Painel atualizado com sucesso."); | |
| } catch (error) { | |
| if (error.name === "AbortError" && activeController !== controller) return; | |
| setStatus("error"); | |
| const message = error.name === "AbortError" | |
| ? "A atualização demorou mais que o esperado. Tente novamente." | |
| : `Falha ao carregar o painel: ${error.message}`; | |
| warnings([message]); | |
| setRegionsBusy(false); | |
| if (announce) showToast("Não foi possível atualizar o painel."); | |
| } finally { | |
| clearTimeout(timeout); | |
| if (activeController === controller) { | |
| activeController = null; | |
| refreshButton.classList.remove("loading"); | |
| refreshButton.disabled = false; | |
| refreshButton.setAttribute("aria-label", "Atualizar painel"); | |
| } | |
| } | |
| } | |
| window.evalOdd = function evalOdd(input, probability) { | |
| if (!input) return; | |
| const result = input.closest(".oddbox")?.querySelector(".oddresult"); | |
| if (!result) return; | |
| const odd = finite(input.value); | |
| const p = finite(probability); | |
| result.className = "oddresult"; | |
| if (odd === null || odd <= 1) { | |
| result.textContent = input.value ? "Digite uma odd válida, acima de 1,00." : "Digite uma odd para estimar o valor esperado."; | |
| return; | |
| } | |
| if (p === null || p <= 0) { | |
| result.textContent = "Probabilidade indisponível para esta comparação."; | |
| return; | |
| } | |
| const expectedValue = p * odd - 1; | |
| const fairOdd = 1 / p; | |
| if (expectedValue >= .03) { | |
| result.classList.add("good"); | |
| result.textContent = `Valor positivo · EV +${num(expectedValue * 100, 1)}% · odd justa ${num(fairOdd, 2)}`; | |
| } else if (expectedValue >= -.02) { | |
| result.classList.add("fair"); | |
| result.textContent = `Preço próximo do justo · EV ${num(expectedValue * 100, 1)}% · justa ${num(fairOdd, 2)}`; | |
| } else { | |
| result.classList.add("weak"); | |
| result.textContent = `Preço abaixo do modelo · EV ${num(expectedValue * 100, 1)}% · justa ${num(fairOdd, 2)}`; | |
| } | |
| }; | |
| async function writeClipboard(text) { | |
| if (navigator.clipboard && window.isSecureContext) { | |
| await navigator.clipboard.writeText(text); | |
| return; | |
| } | |
| const field = document.createElement("textarea"); | |
| field.value = text; | |
| field.setAttribute("readonly", ""); | |
| field.className = "clipboard-fallback"; | |
| document.body.appendChild(field); | |
| field.select(); | |
| const copied = document.execCommand("copy"); | |
| field.remove(); | |
| if (!copied) throw new Error("cópia indisponível"); | |
| } | |
| window.copyText = async function copyText(button) { | |
| if (!button) return; | |
| const text = button.dataset.copy || ""; | |
| if (!text) return; | |
| try { | |
| await writeClipboard(text); | |
| showToast("Copiado para a área de transferência."); | |
| } catch (_error) { | |
| showToast("Não foi possível copiar automaticamente."); | |
| } | |
| }; | |
| document.addEventListener("click", (event) => { | |
| const copyButton = event.target.closest(".copybtn"); | |
| if (copyButton) window.copyText(copyButton); | |
| const scrollButton = event.target.closest("[data-scroll-target]"); | |
| if (scrollButton) $(scrollButton.dataset.scrollTarget)?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| }); | |
| document.addEventListener("input", (event) => { | |
| const input = event.target.closest(".market-odd-input"); | |
| if (input) window.evalOdd(input, input.dataset.probability); | |
| }); | |
| $("refresh-ui").addEventListener("click", () => load({ force: true, announce: true })); | |
| load(); | |
| setInterval(() => { | |
| if (!document.hidden) load(); | |
| }, REFRESH_INTERVAL); | |