Vscode / app /static /app.js
Erinaldorodrigues's picture
Improve radar visibility and cache refresh
b4bbe97
Raw
History Blame Contribute Delete
26.4 kB
"use strict";
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
"'": "&#39;",
'"': "&quot;"
})[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);