Live dashboard with real-time WebSocket updates, analytics page with time-filtered stats, ClickHouse storage, and Caddy reverse proxy.
580 lines
18 KiB
HTML
580 lines
18 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Teen Patti Analytics</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
|
<style>
|
|
:root {
|
|
--bg: #0f1117;
|
|
--surface: #1a1d27;
|
|
--surface2: #232736;
|
|
--surface3: #2a2e42;
|
|
--border: #2d3148;
|
|
--text: #e4e6f0;
|
|
--text2: #8b8fa3;
|
|
--text3: #5a5f75;
|
|
--accent: #6c5ce7;
|
|
--chair-a: #3b82f6;
|
|
--chair-b: #ec4899;
|
|
--chair-c: #f59e0b;
|
|
--green: #10b981;
|
|
--red: #ef4444;
|
|
}
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, 'Segoe UI', Roboto, sans-serif;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
min-height: 100vh;
|
|
}
|
|
|
|
/* Header */
|
|
.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-link {
|
|
font-size: 12px; color: var(--accent); text-decoration: none;
|
|
font-weight: 600; transition: color 0.2s;
|
|
}
|
|
.nav-link:hover { color: #a78bfa; }
|
|
|
|
/* Period Selector */
|
|
.period-bar {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 10px 20px;
|
|
background: var(--surface);
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.period-label { font-size: 11px; color: var(--text2); font-weight: 600; margin-right: 4px; }
|
|
.period-btn {
|
|
padding: 4px 12px; border-radius: 4px; font-size: 11px;
|
|
font-weight: 700; border: 1px solid var(--border);
|
|
background: var(--surface2); color: var(--text2);
|
|
cursor: pointer; transition: all 0.2s;
|
|
}
|
|
.period-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
.period-btn.active {
|
|
background: var(--accent); border-color: var(--accent); color: #fff;
|
|
}
|
|
.loading-indicator {
|
|
font-size: 11px; color: var(--text3); margin-left: auto;
|
|
}
|
|
|
|
/* Content */
|
|
.content { padding: 16px 20px; max-width: 1200px; margin: 0 auto; }
|
|
|
|
/* Summary Cards */
|
|
.summary-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, 1fr);
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.summary-card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
text-align: center;
|
|
}
|
|
.summary-card-label {
|
|
font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
|
|
color: var(--text3); font-weight: 700;
|
|
}
|
|
.summary-card-value {
|
|
font-size: 22px; font-weight: 800; margin-top: 4px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
/* Section */
|
|
.section {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 8px;
|
|
padding: 14px;
|
|
margin-bottom: 16px;
|
|
}
|
|
.section-title {
|
|
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
|
|
color: var(--text2); margin-bottom: 10px; font-weight: 700;
|
|
}
|
|
|
|
/* Chart */
|
|
.chart-container { position: relative; height: 200px; }
|
|
|
|
/* Two column layout */
|
|
.two-col {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 16px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
/* Horizontal Bar */
|
|
.hbar-row {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 5px 0; font-size: 13px;
|
|
}
|
|
.hbar-label {
|
|
font-weight: 700; min-width: 80px; font-size: 12px;
|
|
}
|
|
.hbar-bg {
|
|
flex: 1; height: 22px; border-radius: 4px;
|
|
background: var(--surface3); overflow: hidden;
|
|
}
|
|
.hbar-fill {
|
|
height: 100%; border-radius: 4px;
|
|
transition: width 0.4s ease; min-width: 2px;
|
|
}
|
|
.hbar-value {
|
|
font-weight: 700; min-width: 60px; text-align: right;
|
|
font-variant-numeric: tabular-nums; font-size: 12px;
|
|
}
|
|
.hbar-pct {
|
|
font-size: 11px; color: var(--text3); min-width: 40px; text-align: right;
|
|
}
|
|
|
|
/* Leaderboard Table */
|
|
.lb-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
.lb-table th {
|
|
text-align: left; padding: 6px 8px;
|
|
border-bottom: 2px solid var(--border);
|
|
color: var(--text3); font-size: 10px;
|
|
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
|
|
}
|
|
.lb-table td { padding: 5px 8px; border-bottom: 1px solid #2d314830; }
|
|
.lb-table tr:hover { background: var(--surface2); }
|
|
.lb-table .positive { color: var(--green); }
|
|
.lb-table .negative { color: var(--red); }
|
|
.lb-table .rank-col { width: 30px; color: var(--text3); font-weight: 700; }
|
|
.lb-table .name-col {
|
|
max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.lb-table .num-col {
|
|
text-align: right; font-variant-numeric: tabular-nums; font-weight: 600;
|
|
}
|
|
.lb-table .pnl-col {
|
|
text-align: right; font-variant-numeric: tabular-nums; font-weight: 800;
|
|
}
|
|
|
|
/* History Table */
|
|
.history-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
|
.history-table th {
|
|
text-align: left; padding: 5px 6px;
|
|
border-bottom: 2px solid var(--border);
|
|
color: var(--text3); font-size: 10px;
|
|
text-transform: uppercase; letter-spacing: 0.5px; font-weight: 700;
|
|
}
|
|
.history-table td { padding: 4px 6px; border-bottom: 1px solid #2d314830; }
|
|
.history-table tr:hover { background: var(--surface2); }
|
|
.winner-cell { font-weight: 800; }
|
|
.winner-A { color: var(--chair-a); }
|
|
.winner-B { color: var(--chair-b); }
|
|
.winner-C { color: var(--chair-c); }
|
|
.history-hand { font-family: 'SF Mono', Consolas, monospace; font-size: 11px; white-space: nowrap; }
|
|
.history-hand.winning-hand { font-weight: 700; }
|
|
.history-hand.losing-hand { opacity: 0.4; }
|
|
.history-pot { font-size: 9px; color: var(--text3); font-variant-numeric: tabular-nums; margin-right: 3px; }
|
|
.hand-tag {
|
|
font-size: 9px; padding: 1px 4px; border-radius: 3px;
|
|
margin-left: 4px; font-weight: 600; display: inline-block;
|
|
vertical-align: middle;
|
|
}
|
|
.hand-tag-win { background: #10b98120; color: var(--green); }
|
|
.hand-tag-type { background: var(--surface3); color: var(--text2); }
|
|
.card-red { color: #ef4444; }
|
|
.card-white { color: #e4e6f0; }
|
|
.bet-rank {
|
|
font-size: 9px; padding: 2px 6px; border-radius: 3px;
|
|
font-weight: 700; letter-spacing: 0.3px; text-transform: uppercase;
|
|
}
|
|
.bet-rank-low { background: #10b98125; color: #34d399; }
|
|
.bet-rank-mid { background: #f59e0b25; color: #fbbf24; }
|
|
.bet-rank-high { background: #ef444425; color: #f87171; }
|
|
|
|
.history-scroll { max-height: 500px; overflow-y: auto; }
|
|
|
|
@media (max-width: 768px) {
|
|
.summary-row { grid-template-columns: repeat(3, 1fr); }
|
|
.two-col { grid-template-columns: 1fr; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<h1>Teen Patti Analytics</h1>
|
|
<a href="/" class="nav-link">Live Dashboard →</a>
|
|
</div>
|
|
|
|
<div class="period-bar">
|
|
<span class="period-label">Period:</span>
|
|
<button class="period-btn" data-period="1h">1H</button>
|
|
<button class="period-btn" data-period="6h">6H</button>
|
|
<button class="period-btn" data-period="24h">24H</button>
|
|
<button class="period-btn" data-period="7d">7D</button>
|
|
<button class="period-btn active" data-period="all">ALL</button>
|
|
<span class="loading-indicator" id="loading" style="display:none">Loading...</span>
|
|
</div>
|
|
|
|
<div class="content">
|
|
<!-- Summary Cards -->
|
|
<div class="summary-row">
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Total Games</div>
|
|
<div class="summary-card-value" id="s-games">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Volume</div>
|
|
<div class="summary-card-value" id="s-volume">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Avg Pot</div>
|
|
<div class="summary-card-value" id="s-avg-pot">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Bets Placed</div>
|
|
<div class="summary-card-value" id="s-bets">-</div>
|
|
</div>
|
|
<div class="summary-card">
|
|
<div class="summary-card-label">Unique Bettors</div>
|
|
<div class="summary-card-value" id="s-bettors">-</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Volume Over Time Chart -->
|
|
<div class="section">
|
|
<div class="section-title">Volume Over Time</div>
|
|
<div class="chart-container">
|
|
<canvas id="volume-chart"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Win Distribution + Hand Type Distribution -->
|
|
<div class="two-col">
|
|
<div class="section">
|
|
<div class="section-title">Win Distribution</div>
|
|
<div id="win-dist"></div>
|
|
<div class="section-title" style="margin-top:14px">Winner Bet Rank</div>
|
|
<div id="bet-rank-dist"></div>
|
|
</div>
|
|
<div class="section">
|
|
<div class="section-title">Hand Type Distribution</div>
|
|
<div id="hand-type-dist"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Leaderboard -->
|
|
<div class="section">
|
|
<div class="section-title">Leaderboard (Top 20 by P&L)</div>
|
|
<table class="lb-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>Name</th>
|
|
<th style="text-align:right">Bets</th>
|
|
<th style="text-align:right">Wins</th>
|
|
<th style="text-align:right">W%</th>
|
|
<th style="text-align:right">P&L</th>
|
|
<th style="text-align:right">Wagered</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="lb-body"></tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Game History -->
|
|
<div class="section">
|
|
<div class="section-title">Game History</div>
|
|
<div class="history-scroll">
|
|
<table class="history-table">
|
|
<thead>
|
|
<tr>
|
|
<th>#</th>
|
|
<th>W</th>
|
|
<th>Pot</th>
|
|
<th>Bet</th>
|
|
<th>Hand A</th>
|
|
<th>Hand B</th>
|
|
<th>Hand C</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="history-body"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const fmt = n => {
|
|
if (n === null || n === undefined) return '0';
|
|
const abs = Math.abs(n);
|
|
if (abs >= 1e6) return (n/1e6).toFixed(1) + 'M';
|
|
if (abs >= 1e3) return (n/1e3).toFixed(1) + 'K';
|
|
return String(n);
|
|
};
|
|
const fmtFull = n => {
|
|
if (n === null || n === undefined) return '0';
|
|
return Number(n).toLocaleString();
|
|
};
|
|
const escHtml = s => {
|
|
if (!s) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
};
|
|
|
|
const CHAIRS = {1:'A', 2:'B', 3:'C'};
|
|
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
|
const HAND_TYPES = {1:'High Card', 2:'Pair', 3:'Flush', 4:'Straight', 5:'Str. Flush', 6:'Trail'};
|
|
|
|
function colorCard(text) {
|
|
return text.replace(/([^\s]+)/g, m => {
|
|
if (m.includes('\u2665') || m.includes('\u2666'))
|
|
return `<span class="card-red">${m}</span>`;
|
|
return `<span class="card-white">${m}</span>`;
|
|
});
|
|
}
|
|
|
|
function getBetRank(betA, betB, betC, winnerChair) {
|
|
const bets = [betA || 0, betB || 0, betC || 0];
|
|
const winIdx = winnerChair === 'A' ? 0 : winnerChair === 'B' ? 1 : 2;
|
|
const winBet = bets[winIdx];
|
|
if (winBet === 0) return null;
|
|
const sorted = [...bets].sort((a, b) => a - b);
|
|
if (winBet <= sorted[0]) return 'low';
|
|
if (winBet >= sorted[2]) return 'high';
|
|
return 'mid';
|
|
}
|
|
|
|
function betRankCell(rank) {
|
|
if (!rank) return '<td>—</td>';
|
|
return `<td><span class="bet-rank bet-rank-${rank}">${rank}</span></td>`;
|
|
}
|
|
|
|
function buildHandCell(handStr, handTypeId, isWinner, chairBet) {
|
|
if (!handStr) return '<td>—</td>';
|
|
const cls = isWinner ? 'history-hand winning-hand' : 'history-hand losing-hand';
|
|
const typeName = HAND_TYPES[handTypeId] || '';
|
|
let tag = '';
|
|
if (isWinner) {
|
|
tag = `<span class="hand-tag hand-tag-win">WIN</span>`;
|
|
} else if (handTypeId >= 2) {
|
|
tag = `<span class="hand-tag hand-tag-type">${typeName}</span>`;
|
|
}
|
|
const potStr = chairBet ? `<span class="history-pot">${fmt(chairBet)}</span>` : '';
|
|
return `<td>${potStr}<span class="${cls}">${colorCard(handStr)}</span>${tag}</td>`;
|
|
}
|
|
|
|
// ── Chart ──
|
|
let volumeChart;
|
|
function initChart() {
|
|
const ctx = $('volume-chart').getContext('2d');
|
|
volumeChart = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: [],
|
|
datasets: [
|
|
{
|
|
label: 'Games',
|
|
data: [],
|
|
backgroundColor: '#6c5ce740',
|
|
borderColor: '#6c5ce7',
|
|
borderWidth: 1,
|
|
yAxisID: 'y',
|
|
order: 2,
|
|
},
|
|
{
|
|
label: 'Volume',
|
|
data: [],
|
|
type: 'line',
|
|
borderColor: '#10b981',
|
|
backgroundColor: '#10b98118',
|
|
borderWidth: 2,
|
|
pointRadius: 2,
|
|
fill: true,
|
|
tension: 0.3,
|
|
yAxisID: 'y1',
|
|
order: 1,
|
|
},
|
|
]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 300 },
|
|
plugins: {
|
|
legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } },
|
|
},
|
|
scales: {
|
|
x: {
|
|
ticks: { color: '#5a5f75', font: { size: 9 }, maxRotation: 45 },
|
|
grid: { color: '#2d314820' },
|
|
},
|
|
y: {
|
|
position: 'left',
|
|
title: { display: true, text: 'Games', color: '#5a5f75', font: { size: 10 } },
|
|
ticks: { color: '#5a5f75', font: { size: 10 } },
|
|
grid: { color: '#2d314830' },
|
|
},
|
|
y1: {
|
|
position: 'right',
|
|
title: { display: true, text: 'Volume', color: '#5a5f75', font: { size: 10 } },
|
|
ticks: { color: '#5a5f75', callback: v => fmt(v), font: { size: 10 } },
|
|
grid: { drawOnChartArea: false },
|
|
},
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Rendering ──
|
|
function renderSummary(s) {
|
|
$('s-games').textContent = fmtFull(s.total_games);
|
|
$('s-volume').textContent = fmt(s.total_volume);
|
|
$('s-avg-pot').textContent = fmt(s.avg_pot);
|
|
$('s-bets').textContent = fmtFull(s.total_bets_placed);
|
|
$('s-bettors').textContent = fmtFull(s.unique_bettors);
|
|
}
|
|
|
|
function renderVolumeChart(hourly) {
|
|
const labels = hourly.map(h => {
|
|
const d = new Date(h.hour);
|
|
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
});
|
|
volumeChart.data.labels = labels;
|
|
volumeChart.data.datasets[0].data = hourly.map(h => h.games);
|
|
volumeChart.data.datasets[1].data = hourly.map(h => h.volume);
|
|
volumeChart.update();
|
|
}
|
|
|
|
function renderHBar(container, items, colorFn) {
|
|
const total = items.reduce((s, i) => s + i.value, 0) || 1;
|
|
const max = Math.max(...items.map(i => i.value)) || 1;
|
|
container.innerHTML = items.map(item => {
|
|
const pct = (item.value / max * 100).toFixed(0);
|
|
const totalPct = (item.value / total * 100).toFixed(1);
|
|
const color = colorFn(item.label);
|
|
return `
|
|
<div class="hbar-row">
|
|
<span class="hbar-label" style="color:${color}">${item.label}</span>
|
|
<div class="hbar-bg">
|
|
<div class="hbar-fill" style="width:${pct}%;background:${color}"></div>
|
|
</div>
|
|
<span class="hbar-value">${fmtFull(item.value)}</span>
|
|
<span class="hbar-pct">${totalPct}%</span>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderWinDist(dist) {
|
|
const chairs = dist.chairs;
|
|
renderHBar($('win-dist'), [
|
|
{ label: 'A', value: chairs.A || 0 },
|
|
{ label: 'B', value: chairs.B || 0 },
|
|
{ label: 'C', value: chairs.C || 0 },
|
|
], l => CHAIR_COLORS[l]);
|
|
|
|
const br = dist.bet_rank;
|
|
renderHBar($('bet-rank-dist'), [
|
|
{ label: 'High', value: br.high || 0 },
|
|
{ label: 'Mid', value: br.mid || 0 },
|
|
{ label: 'Low', value: br.low || 0 },
|
|
], l => l === 'High' ? '#f87171' : l === 'Mid' ? '#fbbf24' : '#34d399');
|
|
}
|
|
|
|
function renderHandTypes(dist) {
|
|
const order = ['Trail', 'Straight Flush', 'Straight', 'Flush', 'Pair', 'High Card'];
|
|
const items = order
|
|
.filter(t => dist[t] !== undefined)
|
|
.map(t => ({ label: t, value: dist[t] }));
|
|
renderHBar($('hand-type-dist'), items, () => '#6c5ce7');
|
|
}
|
|
|
|
function renderLeaderboard(leaders) {
|
|
const body = $('lb-body');
|
|
body.innerHTML = leaders.map((l, i) => {
|
|
const pnlClass = l.pnl >= 0 ? 'positive' : 'negative';
|
|
const pnlSign = l.pnl >= 0 ? '+' : '';
|
|
const wr = l.total_bets > 0 ? Math.round(l.wins / l.total_bets * 100) : 0;
|
|
return `
|
|
<tr>
|
|
<td class="rank-col">${i + 1}</td>
|
|
<td class="name-col">${escHtml(l.nick_name)}</td>
|
|
<td class="num-col">${l.total_bets}</td>
|
|
<td class="num-col">${l.wins}</td>
|
|
<td class="num-col">${wr}%</td>
|
|
<td class="pnl-col ${pnlClass}">${pnlSign}${fmtFull(l.pnl)}</td>
|
|
<td class="num-col">${fmt(l.total_wagered)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderGames(games) {
|
|
const body = $('history-body');
|
|
body.innerHTML = games.map(g => {
|
|
const w = CHAIRS[g.winner] || '?';
|
|
const rank = getBetRank(g.bet_a, g.bet_b, g.bet_c, w);
|
|
return `
|
|
<tr>
|
|
<td>${g.game_no}</td>
|
|
<td class="winner-cell winner-${w}">${w}</td>
|
|
<td>${fmt(g.total_pot)}</td>
|
|
${betRankCell(rank)}
|
|
${buildHandCell(g.hand_a, g.hand_type_a, w === 'A', g.bet_a)}
|
|
${buildHandCell(g.hand_b, g.hand_type_b, w === 'B', g.bet_b)}
|
|
${buildHandCell(g.hand_c, g.hand_type_c, w === 'C', g.bet_c)}
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
// ── Data Fetching ──
|
|
let currentPeriod = 'all';
|
|
|
|
async function fetchAnalytics(period) {
|
|
currentPeriod = period;
|
|
$('loading').style.display = 'inline';
|
|
// Update active button
|
|
document.querySelectorAll('.period-btn').forEach(btn => {
|
|
btn.classList.toggle('active', btn.dataset.period === period);
|
|
});
|
|
|
|
try {
|
|
const resp = await fetch(`/api/analytics?period=${period}`);
|
|
const data = await resp.json();
|
|
renderSummary(data.summary);
|
|
renderVolumeChart(data.hourly_volume);
|
|
renderWinDist(data.win_distribution);
|
|
renderHandTypes(data.hand_type_distribution);
|
|
renderLeaderboard(data.leaderboard);
|
|
renderGames(data.games);
|
|
} catch (e) {
|
|
console.error('Failed to fetch analytics:', e);
|
|
} finally {
|
|
$('loading').style.display = 'none';
|
|
}
|
|
}
|
|
|
|
// ── Init ──
|
|
document.querySelectorAll('.period-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => fetchAnalytics(btn.dataset.period));
|
|
});
|
|
|
|
initChart();
|
|
fetchAnalytics('all');
|
|
</script>
|
|
</body>
|
|
</html>
|