add bet impact simulator, visitor log page, and fix console logging

- Bet impact simulator on /predictions shows rank headroom and safe bet amounts
- Password-protected /visitors page with visitor log table and stats
- Console now logs real visitor IPs instead of Cloudflare tunnel IPs
This commit is contained in:
2026-02-26 10:19:14 +05:00
parent 86865166ef
commit 9762c0f9bf
4 changed files with 307 additions and 2 deletions

View File

@@ -178,8 +178,26 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.result-flash.loss { background: rgba(239,68,68,0.15); border: 1px solid var(--red); color: var(--red); }
@keyframes flashIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
/* Bet impact simulator */
.impact-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; }
.impact-table th, .impact-table td { padding: 8px 10px; text-align: center; border-bottom: 1px solid var(--border); }
.impact-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); }
.impact-rank { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
.impact-rank.high { background: rgba(239,68,68,0.2); color: var(--red); }
.impact-rank.mid { background: rgba(108,92,231,0.2); color: var(--accent); }
.impact-rank.low { background: rgba(16,185,129,0.2); color: var(--green); }
.impact-headroom { font-weight: 800; font-variant-numeric: tabular-nums; }
.impact-recs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.impact-rec {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
padding: 12px 16px; text-align: center;
}
.impact-rec .rec-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
.impact-rec .rec-value { font-size: 20px; font-weight: 800; }
.impact-rec .rec-note { font-size: 10px; color: var(--text3); margin-top: 4px; }
@media (max-width: 768px) {
.pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats { grid-template-columns: 1fr; }
.pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats, .impact-recs { grid-template-columns: 1fr; }
.advisor-grid { grid-template-columns: 1fr; gap: 10px; }
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
.pred-card .prob { font-size: 28px; }
@@ -229,6 +247,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
<div id="advisor-content" class="advisor-grid"></div>
</div>
<!-- Bet Impact Simulator -->
<div id="bet-impact" class="bet-advisor" style="margin-top:12px">
<div class="panel-title">Bet Impact Simulator</div>
<div id="impact-content"></div>
</div>
<div class="panel" style="margin-top:8px">
<div class="panel-title">Signal Breakdown</div>
<table class="signal-table" id="signal-table">
@@ -556,6 +580,119 @@ function renderBetAdvisor(data, pot) {
${whaleHtml}
${pubHtml}
`;
renderBetImpact();
}
// ── Bet Impact Simulator ──
function getRank(chair, bets) {
const vals = CHAIRS.map(c => bets[c] || 0);
const v = bets[chair] || 0;
const maxV = Math.max(...vals);
const minV = Math.min(...vals);
if (maxV === minV) return 'mid';
if (v >= maxV) return 'high';
if (v <= minV) return 'low';
return 'mid';
}
function rankWinRate(rank) {
const br = predictionData?.bet_rank || {};
const total = (br.high || 0) + (br.mid || 0) + (br.low || 0);
if (total === 0) return null;
return (br[rank] || 0) / total * 100;
}
function computeHeadroom(chair, bets) {
const curRank = getRank(chair, bets);
const others = CHAIRS.filter(c => c !== chair).map(c => bets[c] || 0);
const myBet = bets[chair] || 0;
const maxOther = Math.max(...others);
const minOther = Math.min(...others);
if (curRank === 'low') {
// Can add up to (minOther - myBet) before leaving low
// If two others are equal, second-lowest is minOther
const sorted = others.slice().sort((a, b) => a - b);
return Math.max(0, sorted[0] - myBet);
} else if (curRank === 'mid') {
// Can add up to (maxOther - myBet) before becoming high
return Math.max(0, maxOther - myBet);
} else {
// Already high — headroom is infinite (rank can't go higher)
return Infinity;
}
}
function renderBetImpact() {
const el = $('impact-content');
const totalBets = liveBets.A + liveBets.B + liveBets.C;
if (totalBets === 0) {
el.innerHTML = '<div style="color:var(--text3);font-size:12px;padding:8px 0">Waiting for bets to calculate impact...</div>';
return;
}
// Table
let rows = '';
for (const c of CHAIRS) {
const curRank = getRank(c, liveBets);
const curWR = rankWinRate(curRank);
const headroom = computeHeadroom(c, liveBets);
// What rank would the chair move to?
let nextRank = '--';
let nextWR = null;
let wrChange = '';
if (headroom !== Infinity && headroom < 1e9) {
// Simulate adding headroom+1
const simBets = {...liveBets, [c]: (liveBets[c] || 0) + headroom + 1};
const nr = getRank(c, simBets);
nextRank = nr;
nextWR = rankWinRate(nr);
if (curWR !== null && nextWR !== null) {
const diff = nextWR - curWR;
wrChange = `<span style="color:${diff >= 0 ? 'var(--green)' : 'var(--red)'}">${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%</span>`;
}
}
rows += `<tr>
<td style="font-weight:700;color:${CHAIR_COLORS[c]}">Chair ${c}</td>
<td><span class="impact-rank ${curRank}">${curRank}</span></td>
<td>${curWR !== null ? curWR.toFixed(1) + '%' : '--'}</td>
<td class="impact-headroom" style="color:${headroom === Infinity ? 'var(--green)' : headroom === 0 ? 'var(--red)' : 'var(--text)'}">${headroom === Infinity ? 'No limit' : fmt(headroom)}</td>
<td>${nextRank !== '--' ? `<span class="impact-rank ${nextRank}">${nextRank}</span>` : '--'}</td>
<td>${wrChange || '--'}</td>
</tr>`;
}
// Recommendations: safe bet = 80% of headroom for top pick & 2nd pick
const ranked = CHAIRS.slice().sort((a, b) => (predictionData?.prediction?.[b] || 0) - (predictionData?.prediction?.[a] || 0));
const best = ranked[0], second = ranked[1];
const headBest = computeHeadroom(best, liveBets);
const headSecond = computeHeadroom(second, liveBets);
const safeBest = headBest === Infinity ? 'No limit' : fmt(Math.floor(headBest * 0.8));
const safeSecond = headSecond === Infinity ? 'No limit' : fmt(Math.floor(headSecond * 0.8));
const bestRank = getRank(best, liveBets);
const secondRank = getRank(second, liveBets);
el.innerHTML = `
<table class="impact-table">
<thead><tr><th>Chair</th><th>Rank</th><th>Win Rate</th><th>Max Bet to Keep Rank</th><th>Next Rank</th><th>Win Rate Change</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="impact-recs">
<div class="impact-rec">
<div class="rec-label">Safe Bet on ${best} (Top Pick)</div>
<div class="rec-value" style="color:${CHAIR_COLORS[best]}">${safeBest}</div>
<div class="rec-note">80% of headroom &middot; keeps <span class="impact-rank ${bestRank}" style="font-size:9px">${bestRank}</span> rank</div>
</div>
<div class="impact-rec">
<div class="rec-label">Safe Bet on ${second} (2nd Pick)</div>
<div class="rec-value" style="color:${CHAIR_COLORS[second]}">${safeSecond}</div>
<div class="rec-note">80% of headroom &middot; keeps <span class="impact-rank ${secondRank}" style="font-size:9px">${secondRank}</span> rank</div>
</div>
</div>
`;
}
// ── Whale & Public Trends ──

98
static/visitors.html Normal file
View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Visitor Log</title>
<style>
:root {
--bg: #0f1117; --surface: #1a1d27; --surface2: #232736;
--border: #2d3148; --text: #e4e6f0; --text2: #8b8fa3; --text3: #5a5f75;
--accent: #6c5ce7; --green: #10b981; --red: #ef4444;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
}
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
.nav-links { display: flex; gap: 14px; }
.nav-link { font-size: 12px; color: var(--accent); text-decoration: none; font-weight: 600; }
.nav-link:hover { color: #a78bfa; }
.content { padding: 16px 20px; max-width: 1400px; margin: 0 auto; }
.stats-bar {
display: flex; gap: 24px; margin-bottom: 16px; padding: 14px 20px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
}
.stat-item { text-align: center; }
.stat-item .s-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 2px; }
.stat-item .s-value { font-size: 22px; font-weight: 800; }
.table-wrap {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
overflow: auto; max-height: calc(100vh - 180px);
}
table { width: 100%; border-collapse: collapse; font-size: 12px; }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { background: var(--surface2); color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; position: sticky; top: 0; z-index: 1; }
td { color: var(--text); }
tr:hover td { background: var(--surface2); }
.loading { text-align: center; padding: 60px; color: var(--text2); font-size: 14px; }
.ua { max-width: 300px; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body>
<div class="header">
<h1>Visitor Log</h1>
<div class="nav-links">
<a href="/" class="nav-link">Dashboard</a>
<a href="/predictions" class="nav-link">Predictions</a>
<a href="/analytics" class="nav-link">Analytics</a>
<a href="/patterns" class="nav-link">Patterns</a>
</div>
</div>
<div class="content">
<div id="stats" class="stats-bar" style="display:none"></div>
<div class="table-wrap">
<table>
<thead><tr><th>Time</th><th>IP</th><th>Country</th><th>Method</th><th>Path</th><th>User Agent</th></tr></thead>
<tbody id="tbody"><tr><td colspan="6" class="loading">Loading...</td></tr></tbody>
</table>
</div>
</div>
<script>
fetch('/api/visitors?limit=200')
.then(r => { if (r.status === 401) throw new Error('Auth required'); return r.json(); })
.then(data => {
const tbody = document.getElementById('tbody');
if (!data.length) { tbody.innerHTML = '<tr><td colspan="6" style="color:#5a5f75;text-align:center;padding:40px">No visitors yet</td></tr>'; return; }
const ips = new Set(data.map(v => v.ip));
const countries = new Set(data.filter(v => v.country).map(v => v.country));
const stats = document.getElementById('stats');
stats.style.display = 'flex';
stats.innerHTML = `
<div class="stat-item"><div class="s-label">Total Visits</div><div class="s-value">${data.length}</div></div>
<div class="stat-item"><div class="s-label">Unique IPs</div><div class="s-value" style="color:#10b981">${ips.size}</div></div>
<div class="stat-item"><div class="s-label">Countries</div><div class="s-value" style="color:#6c5ce7">${countries.size}</div></div>
`;
tbody.innerHTML = data.map(v => `<tr>
<td>${v.created_at}</td>
<td>${v.ip}</td>
<td>${v.country || '--'}</td>
<td>${v.method}</td>
<td>${v.path}</td>
<td class="ua" title="${v.user_agent.replace(/"/g, '&quot;')}">${v.user_agent}</td>
</tr>`).join('');
})
.catch(err => {
document.getElementById('tbody').innerHTML = `<tr><td colspan="6" style="color:#ef4444;text-align:center;padding:40px">${err.message}</td></tr>`;
});
</script>
</body>
</html>