add pattern analysis feature with web dashboard and CLI

New /patterns page with 9 analyses: chair win bias, bet rank
correlations, hand type distributions, pot size buckets, streaks,
hourly patterns, and recent-vs-overall comparison. Also adds a
standalone analyze.py CLI script for terminal output.
This commit is contained in:
2026-02-25 22:45:43 +05:00
parent e65b6b2cfb
commit 2b8e3dd456
6 changed files with 824 additions and 1 deletions

166
analyze.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Standalone CLI script for Teen Patti pattern analysis.
Usage:
python analyze.py --host localhost --port 8123
"""
import argparse
import sys
def fmt_pct(n, total):
return f"{n/total*100:.1f}%" if total else "0.0%"
def print_table(headers, rows, col_widths=None):
"""Print a simple formatted table."""
if not col_widths:
col_widths = [max(len(str(h)), *(len(str(r[i])) for r in rows))
for i, h in enumerate(headers)]
# Header
hdr = " ".join(str(h).ljust(w) for h, w in zip(headers, col_widths))
print(hdr)
print("-" * len(hdr))
for row in rows:
print(" ".join(str(c).ljust(w) for c, w in zip(row, col_widths)))
def main():
parser = argparse.ArgumentParser(description="Teen Patti Pattern Analysis CLI")
parser.add_argument("--host", default="localhost", help="ClickHouse host")
parser.add_argument("--port", type=int, default=8123, help="ClickHouse HTTP port")
args = parser.parse_args()
# Set config before importing db
from app import config
config.CLICKHOUSE_HOST = args.host
config.CLICKHOUSE_PORT = args.port
from app import db
print(f"Connecting to ClickHouse at {args.host}:{args.port}...")
try:
data = db.get_pattern_analysis()
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
total = data["chair_bias"]["total_games"]
print(f"\n{'='*60}")
print(f" TEEN PATTI PATTERN ANALYSIS ({total:,} games)")
print(f"{'='*60}\n")
# 1. Chair Win Bias
print("1. CHAIR WIN BIAS (expected 33.3%)")
cb = data["chair_bias"]
rows = []
for ch in ("A", "B", "C"):
d = cb[ch]
diff = d["pct"] - 33.3
sign = "+" if diff >= 0 else ""
rows.append([ch, f"{d['wins']:,}", f"{d['pct']:.1f}%", f"{sign}{diff:.1f}%"])
print_table(["Chair", "Wins", "Win %", "vs Expected"], rows)
# 2. Bet Rank Analysis
print("\n2. BET RANK ANALYSIS")
br = data["bet_rank"]
br_total = br["high"] + br["mid"] + br["low"]
rows = []
for rank in ("high", "mid", "low"):
rows.append([rank.capitalize(), f"{br[rank]:,}", fmt_pct(br[rank], br_total)])
print_table(["Rank", "Wins", "Win %"], rows)
# 3. Per-Chair Bet Rank
print("\n3. PER-CHAIR: HIGHEST BET WIN RATE")
print(" When chair X has the highest bet, how often does X win?")
pcr = data["per_chair_rank"]
rows = []
for ch in ("A", "B", "C"):
d = pcr.get(ch, {})
rows.append([ch, f"{d.get('has_highest', 0):,}",
f"{d.get('wins', 0):,}", f"{d.get('win_pct', 0):.1f}%"])
print_table(["Chair", "Times Highest", "Wins", "Win %"], rows)
# 4. Hand Type Distribution by Chair
print("\n4. HAND TYPE DISTRIBUTION BY CHAIR")
htbc = data["hand_types_by_chair"]
hand_order = ["Trail", "Straight Flush", "Straight", "Flush", "Pair", "High Card"]
rows = []
for ht in hand_order:
a = htbc["A"].get(ht, 0)
b = htbc["B"].get(ht, 0)
c = htbc["C"].get(ht, 0)
if a + b + c == 0:
continue
rows.append([ht, f"{a:,}", f"{b:,}", f"{c:,}"])
print_table(["Hand Type", "Chair A", "Chair B", "Chair C"], rows)
# 5. Hand Type Win Rates
print("\n5. HAND TYPE WIN RATES")
htw = data["hand_type_wins"]
htw_total = sum(htw.values())
rows = []
for ht in hand_order:
v = htw.get(ht, 0)
if v == 0:
continue
rows.append([ht, f"{v:,}", fmt_pct(v, htw_total)])
print_table(["Hand Type", "Wins", "Win %"], rows)
# 6. Pot Size Buckets
print("\n6. WIN RATES BY POT SIZE")
pb = data["pot_buckets"]
ranges = pb.get("_ranges", {})
rows = []
for bucket in ("small", "medium", "large", "whale"):
d = pb.get(bucket)
if not d:
continue
t = d["total"] or 1
rows.append([
bucket.capitalize(), ranges.get(bucket, ""),
f"{d['total']:,}",
f"{d['A']/t*100:.1f}%", f"{d['B']/t*100:.1f}%", f"{d['C']/t*100:.1f}%",
])
print_table(["Bucket", "Range", "Games", "A %", "B %", "C %"], rows)
# 7. Streaks
print("\n7. STREAK ANALYSIS")
streaks = data["streaks"]
rows = []
for ch in ("A", "B", "C"):
s = streaks[ch]
rows.append([ch, str(s["max_streak"]), str(s["current_streak"])])
print_table(["Chair", "Max Streak", "Current Streak"], rows)
# 8. Hourly Patterns
print("\n8. HOURLY PATTERNS (win % by hour)")
hourly = data["hourly"]
hours = sorted(hourly.keys(), key=lambda h: int(h))
rows = []
for h in hours:
d = hourly[h]
t = d["total"] or 1
rows.append([
f"{int(h):02d}:00", str(d["total"]),
f"{d['A']/t*100:.1f}%", f"{d['B']/t*100:.1f}%", f"{d['C']/t*100:.1f}%",
])
print_table(["Hour", "Games", "A %", "B %", "C %"], rows)
# 9. Recent vs Overall
print("\n9. RECENT (LAST 100) vs ALL-TIME")
rva = data["recent_vs_all"]
for label, section in [("All-Time", rva["all"]), ("Last 100", rva["recent"])]:
t = section["total"] or 1
d = section["dist"]
parts = " | ".join(f"{ch}: {d[ch]:>4} ({d[ch]/t*100:.1f}%)" for ch in ("A", "B", "C"))
print(f" {label:>10} [{t:>5} games] {parts}")
print(f"\n{'='*60}")
print(" Done.")
if __name__ == "__main__":
main()

196
app/db.py
View File

@@ -571,6 +571,202 @@ def get_analytics(period: str = "all") -> dict:
} }
@_with_lock
def get_pattern_analysis() -> dict:
"""Run all pattern analysis queries and return a single dict."""
client = get_client()
# 1. Chair win bias
result = client.query(
"SELECT winner, count() AS cnt FROM games GROUP BY winner ORDER BY winner"
)
chair_wins = {}
total_games = 0
for row in result.result_rows:
chair = config.CHAIRS.get(row[0], "?")
chair_wins[chair] = row[1]
total_games += row[1]
chair_bias = {"total_games": total_games}
for ch in ("A", "B", "C"):
wins = chair_wins.get(ch, 0)
pct = round(wins / total_games * 100, 2) if total_games else 0
chair_bias[ch] = {"wins": wins, "pct": pct}
# 2. Bet rank analysis — how often the highest/mid/lowest bet chair wins
rank_result = client.query("""
SELECT
countIf(winner_bet >= greatest(bet_a, bet_b, bet_c)) AS high,
countIf(winner_bet > least(bet_a, bet_b, bet_c)
AND winner_bet < greatest(bet_a, bet_b, bet_c)) AS mid,
countIf(winner_bet <= least(bet_a, bet_b, bet_c)) AS low
FROM (
SELECT bet_a, bet_b, bet_c,
multiIf(winner = 3, bet_a, winner = 2, bet_b, bet_c) AS winner_bet
FROM games WHERE bet_a + bet_b + bet_c > 0
)
""")
bet_rank = {"high": 0, "mid": 0, "low": 0}
if rank_result.result_rows:
r = rank_result.result_rows[0]
bet_rank = {"high": r[0], "mid": r[1], "low": r[2]}
# 3. Per-chair bet rank — when chair X has max bet, how often does X win?
pcr = client.query("""
SELECT
countIf(bet_a >= greatest(bet_a, bet_b, bet_c) AND bet_a > 0) AS a_high,
countIf(bet_a >= greatest(bet_a, bet_b, bet_c) AND bet_a > 0 AND winner = 3) AS a_win,
countIf(bet_b >= greatest(bet_a, bet_b, bet_c) AND bet_b > 0) AS b_high,
countIf(bet_b >= greatest(bet_a, bet_b, bet_c) AND bet_b > 0 AND winner = 2) AS b_win,
countIf(bet_c >= greatest(bet_a, bet_b, bet_c) AND bet_c > 0) AS c_high,
countIf(bet_c >= greatest(bet_a, bet_b, bet_c) AND bet_c > 0 AND winner = 1) AS c_win
FROM games WHERE bet_a + bet_b + bet_c > 0
""")
per_chair_rank = {}
if pcr.result_rows:
r = pcr.result_rows[0]
for i, ch in enumerate(("A", "B", "C")):
has = r[i * 2]
wins = r[i * 2 + 1]
per_chair_rank[ch] = {
"has_highest": has, "wins": wins,
"win_pct": round(wins / has * 100, 2) if has else 0,
}
# 4. Hand type distribution by chair (all dealt hands, not just winners)
ht_result = client.query("""
SELECT 'A' AS chair, hand_type_a AS ht, count() AS cnt
FROM games WHERE hand_type_a > 0 GROUP BY ht
UNION ALL
SELECT 'B', hand_type_b, count()
FROM games WHERE hand_type_b > 0 GROUP BY hand_type_b
UNION ALL
SELECT 'C', hand_type_c, count()
FROM games WHERE hand_type_c > 0 GROUP BY hand_type_c
ORDER BY chair, ht
""")
hand_types_by_chair = {"A": {}, "B": {}, "C": {}}
for row in ht_result.result_rows:
ch = row[0]
type_name = config.HAND_TYPES.get(row[1], f"Type {row[1]}")
hand_types_by_chair[ch][type_name] = row[2]
# 5. Hand type win rates (winning hand type distribution)
htw = client.query("""
SELECT hand_type, count() AS cnt FROM (
SELECT multiIf(winner = 3, hand_type_a, winner = 2, hand_type_b, hand_type_c) AS hand_type
FROM games
) WHERE hand_type > 0
GROUP BY hand_type ORDER BY hand_type
""")
hand_type_wins = {}
for row in htw.result_rows:
type_name = config.HAND_TYPES.get(row[0], f"Type {row[0]}")
hand_type_wins[type_name] = row[1]
# 6. Pot size buckets — win rates by pot quartile
qr = client.query("""
SELECT
quantile(0.25)(total_pot) AS q1,
quantile(0.5)(total_pot) AS q2,
quantile(0.75)(total_pot) AS q3
FROM games
""")
pot_buckets = {}
if qr.result_rows:
q1, q2, q3 = int(qr.result_rows[0][0]), int(qr.result_rows[0][1]), int(qr.result_rows[0][2])
br = client.query(f"""
SELECT
multiIf(
total_pot <= {q1}, 'small',
total_pot <= {q2}, 'medium',
total_pot <= {q3}, 'large',
'whale'
) AS bucket,
winner, count() AS cnt
FROM games GROUP BY bucket, winner
""")
for row in br.result_rows:
bucket, chair_id, cnt = row[0], row[1], row[2]
chair = config.CHAIRS.get(chair_id, "?")
if bucket not in pot_buckets:
pot_buckets[bucket] = {"A": 0, "B": 0, "C": 0, "total": 0}
pot_buckets[bucket][chair] = cnt
pot_buckets[bucket]["total"] += cnt
pot_buckets["_ranges"] = {
"small": f"0{q1}", "medium": f"{q1+1}{q2}",
"large": f"{q2+1}{q3}", "whale": f">{q3}",
}
# 7. Streak analysis — compute in Python from ordered winners
streak_result = client.query(
"SELECT winner FROM games ORDER BY game_no ASC"
)
winners_list = [config.CHAIRS.get(r[0], "?") for r in streak_result.result_rows]
streaks = {}
for ch in ("A", "B", "C"):
max_s = cur = 0
for w in winners_list:
if w == ch:
cur += 1
max_s = max(max_s, cur)
else:
cur = 0
# current streak from the end
current = 0
for w in reversed(winners_list):
if w == ch:
current += 1
else:
break
streaks[ch] = {"max_streak": max_s, "current_streak": current}
# 8. Hourly patterns — win rates by hour of day
hr_result = client.query("""
SELECT toHour(created_at) AS hr, winner, count() AS cnt
FROM games GROUP BY hr, winner ORDER BY hr, winner
""")
hourly = {}
for row in hr_result.result_rows:
h = str(row[0])
chair = config.CHAIRS.get(row[1], "?")
if h not in hourly:
hourly[h] = {"A": 0, "B": 0, "C": 0, "total": 0}
hourly[h][chair] = row[2]
hourly[h]["total"] += row[2]
# 9. Recent (last 100) vs overall
recent = client.query("""
SELECT winner, count() AS cnt FROM (
SELECT winner FROM games ORDER BY game_no DESC LIMIT 100
) GROUP BY winner
""")
recent_dist = {"A": 0, "B": 0, "C": 0}
recent_total = 0
for row in recent.result_rows:
chair = config.CHAIRS.get(row[0], "?")
if chair in recent_dist:
recent_dist[chair] = row[1]
recent_total += row[1]
return {
"chair_bias": chair_bias,
"bet_rank": bet_rank,
"per_chair_rank": per_chair_rank,
"hand_types_by_chair": hand_types_by_chair,
"hand_type_wins": hand_type_wins,
"pot_buckets": pot_buckets,
"streaks": streaks,
"hourly": hourly,
"recent_vs_all": {
"recent": {"dist": recent_dist, "total": recent_total},
"all": {
"dist": {ch: chair_bias[ch]["wins"] for ch in ("A", "B", "C")},
"total": total_games,
},
},
}
@_with_lock @_with_lock
def get_hot_cold_players(n: int = 5) -> dict: def get_hot_cold_players(n: int = 5) -> dict:
""" """

View File

@@ -41,6 +41,8 @@ class WebServer:
self.app.router.add_get("/api/hot-cold", self._handle_hot_cold) self.app.router.add_get("/api/hot-cold", self._handle_hot_cold)
self.app.router.add_get("/analytics", self._handle_analytics_page) self.app.router.add_get("/analytics", self._handle_analytics_page)
self.app.router.add_get("/api/analytics", self._handle_analytics) self.app.router.add_get("/api/analytics", self._handle_analytics)
self.app.router.add_get("/patterns", self._handle_patterns_page)
self.app.router.add_get("/api/patterns", self._handle_patterns)
self.app.router.add_get("/ws", self._handle_ws) self.app.router.add_get("/ws", self._handle_ws)
self.app.router.add_static("/static/", STATIC_DIR, name="static") self.app.router.add_static("/static/", STATIC_DIR, name="static")
@@ -90,6 +92,18 @@ class WebServer:
path = os.path.join(STATIC_DIR, "analytics.html") path = os.path.join(STATIC_DIR, "analytics.html")
return web.FileResponse(path) return web.FileResponse(path)
async def _handle_patterns_page(self, request: web.Request) -> web.Response:
path = os.path.join(STATIC_DIR, "patterns.html")
return web.FileResponse(path)
async def _handle_patterns(self, request: web.Request) -> web.Response:
try:
data = await _run_sync(db.get_pattern_analysis)
return web.json_response(data)
except Exception as e:
log.error("Pattern analysis query failed: %s", e)
return web.json_response({"error": str(e)}, status=500)
async def _handle_analytics(self, request: web.Request) -> web.Response: async def _handle_analytics(self, request: web.Request) -> web.Response:
period = request.query.get("period", "all") period = request.query.get("period", "all")
if period not in ("1h", "6h", "24h", "7d", "all"): if period not in ("1h", "6h", "24h", "7d", "all"):

View File

@@ -212,7 +212,10 @@
<div class="header"> <div class="header">
<h1>Teen Patti Analytics</h1> <h1>Teen Patti Analytics</h1>
<div style="display:flex;gap:14px">
<a href="/" class="nav-link">Live Dashboard &rarr;</a> <a href="/" class="nav-link">Live Dashboard &rarr;</a>
<a href="/patterns" class="nav-link">Patterns &rarr;</a>
</div>
</div> </div>
<div class="period-bar"> <div class="period-bar">

View File

@@ -602,6 +602,7 @@
</div> </div>
<div class="status"> <div class="status">
<a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics &rarr;</a> <a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics &rarr;</a>
<a href="/patterns" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Patterns &rarr;</a>
<div id="status-dot" class="status-dot"></div> <div id="status-dot" class="status-dot"></div>
<span id="status-text">Connecting...</span> <span id="status-text">Connecting...</span>
</div> </div>

443
static/patterns.html Normal file
View File

@@ -0,0 +1,443 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Teen Patti Pattern Analysis</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 {
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; transition: color 0.2s;
}
.nav-link:hover { color: #a78bfa; }
.content { padding: 16px 20px; max-width: 1200px; margin: 0 auto; }
.loading {
text-align: center; padding: 60px; color: var(--text2); font-size: 14px;
}
.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;
}
.section-desc {
font-size: 11px; color: var(--text3); margin-bottom: 12px;
}
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 16px; }
.three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; margin-bottom: 16px; }
/* Tables */
.ptable { width: 100%; border-collapse: collapse; font-size: 12px; }
.ptable 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;
}
.ptable td { padding: 5px 8px; border-bottom: 1px solid #2d314830; }
.ptable tr:hover { background: var(--surface2); }
.ptable .num { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
.ptable .pct { text-align: right; font-variant-numeric: tabular-nums; color: var(--text2); }
.positive { color: var(--green); }
.negative { color: var(--red); }
.chair-a { color: var(--chair-a); font-weight: 700; }
.chair-b { color: var(--chair-b); font-weight: 700; }
.chair-c { color: var(--chair-c); font-weight: 700; }
/* H-bars */
.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: 50px; text-align: right; font-variant-numeric: tabular-nums; font-size: 12px; }
.hbar-pct { font-size: 11px; color: var(--text3); min-width: 45px; text-align: right; }
/* Chart */
.chart-container { position: relative; height: 250px; }
/* Streak cards */
.streak-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.streak-card {
background: var(--surface2); border-radius: 8px; padding: 12px; text-align: center;
border: 1px solid var(--border);
}
.streak-card-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text3); font-weight: 700; }
.streak-card-value { font-size: 28px; font-weight: 800; margin-top: 4px; }
.streak-card-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
/* Comparison */
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.compare-panel {
background: var(--surface2); border-radius: 8px; padding: 12px;
border: 1px solid var(--border);
}
.compare-title {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.8px;
color: var(--text2); font-weight: 700; margin-bottom: 8px;
}
@media (max-width: 768px) {
.two-col, .three-col { grid-template-columns: 1fr; }
.streak-cards { grid-template-columns: 1fr; }
.compare-grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>Pattern Analysis</h1>
<div class="nav-links">
<a href="/" class="nav-link">Live Dashboard &rarr;</a>
<a href="/analytics" class="nav-link">Analytics &rarr;</a>
</div>
</div>
<div class="content">
<div class="loading" id="loading">Loading pattern analysis...</div>
<div id="main" style="display:none">
<!-- 1. Chair Win Bias -->
<div class="two-col">
<div class="section">
<div class="section-title">Chair Win Bias</div>
<div class="section-desc">Win % per chair vs expected 33.3%. Sample: <span id="bias-n">0</span> games.</div>
<div id="chair-bias-bars"></div>
</div>
<div class="section">
<div class="section-title">Chair Win Distribution</div>
<div class="chart-container" style="height:200px">
<canvas id="chair-pie"></canvas>
</div>
</div>
</div>
<!-- 2 & 3. Bet Rank Analysis -->
<div class="two-col">
<div class="section">
<div class="section-title">Winner Bet Rank</div>
<div class="section-desc">How often does the winning chair have the highest, mid, or lowest bet?</div>
<div id="bet-rank-bars"></div>
</div>
<div class="section">
<div class="section-title">Per-Chair: Highest Bet Win Rate</div>
<div class="section-desc">When chair X has the highest bet, how often does X win?</div>
<table class="ptable">
<thead><tr><th>Chair</th><th style="text-align:right">Times Highest</th><th style="text-align:right">Wins</th><th style="text-align:right">Win %</th></tr></thead>
<tbody id="pcr-body"></tbody>
</table>
</div>
</div>
<!-- 4 & 5. Hand Type Distribution -->
<div class="two-col">
<div class="section">
<div class="section-title">Hand Type Distribution by Chair</div>
<div class="section-desc">Are better hands dealt to certain chairs more often?</div>
<div class="chart-container">
<canvas id="hand-type-chart"></canvas>
</div>
</div>
<div class="section">
<div class="section-title">Hand Type Win Rates</div>
<div class="section-desc">Which hand types win most often?</div>
<div id="hand-type-wins-bars"></div>
</div>
</div>
<!-- 6. Pot Size Buckets -->
<div class="section">
<div class="section-title">Win Rates by Pot Size</div>
<div class="section-desc">Does the pot size affect which chair wins?</div>
<table class="ptable">
<thead>
<tr><th>Bucket</th><th>Range</th><th style="text-align:right">Games</th>
<th style="text-align:right">A %</th><th style="text-align:right">B %</th><th style="text-align:right">C %</th></tr>
</thead>
<tbody id="pot-body"></tbody>
</table>
</div>
<!-- 7. Streak Analysis -->
<div class="section">
<div class="section-title">Streak Analysis</div>
<div class="streak-cards" id="streak-cards"></div>
</div>
<!-- 8. Hourly Patterns -->
<div class="section">
<div class="section-title">Hourly Win Patterns</div>
<div class="section-desc">Win rates by hour of day (server time).</div>
<div class="chart-container">
<canvas id="hourly-chart"></canvas>
</div>
</div>
<!-- 9. Recent vs Overall -->
<div class="section">
<div class="section-title">Recent (Last 100) vs All-Time</div>
<div class="section-desc">Spot shifts in chair dominance.</div>
<div class="compare-grid">
<div class="compare-panel">
<div class="compare-title">All-Time</div>
<div id="compare-all"></div>
</div>
<div class="compare-panel">
<div class="compare-title">Last 100 Games</div>
<div id="compare-recent"></div>
</div>
</div>
</div>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const fmt = n => {
if (n == null) 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 => n == null ? '0' : Number(n).toLocaleString();
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
const HAND_ORDER = ['Trail', 'Straight Flush', 'Straight', 'Flush', 'Pair', 'High Card'];
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 renderComparePanel(el, dist, total) {
const items = ['A','B','C'].map(ch => ({label: ch, value: dist[ch] || 0}));
renderHBar(el, items, l => CHAIR_COLORS[l]);
const note = document.createElement('div');
note.style.cssText = 'font-size:10px;color:var(--text3);margin-top:6px';
note.textContent = `${total} games`;
el.appendChild(note);
}
function render(data) {
// 1. Chair win bias
$('bias-n').textContent = fmtFull(data.chair_bias.total_games);
const biasItems = ['A','B','C'].map(ch => ({
label: `Chair ${ch}`, value: data.chair_bias[ch].wins,
}));
renderHBar($('chair-bias-bars'), biasItems, l => CHAIR_COLORS[l.replace('Chair ', '')]);
// Chair pie
new Chart($('chair-pie'), {
type: 'doughnut',
data: {
labels: ['A','B','C'],
datasets: [{
data: ['A','B','C'].map(ch => data.chair_bias[ch].wins),
backgroundColor: ['#3b82f6','#ec4899','#f59e0b'],
borderWidth: 0,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8fa3', font: { size: 11 } } },
tooltip: {
callbacks: {
label: ctx => {
const v = ctx.raw;
const t = data.chair_bias.total_games || 1;
return ` ${ctx.label}: ${v} (${(v/t*100).toFixed(1)}%)`;
}
}
}
}
}
});
// 2. Bet rank
const br = data.bet_rank;
renderHBar($('bet-rank-bars'), [
{label: 'High Bet', value: br.high},
{label: 'Mid Bet', value: br.mid},
{label: 'Low Bet', value: br.low},
], l => l.startsWith('High') ? '#f87171' : l.startsWith('Mid') ? '#fbbf24' : '#34d399');
// 3. Per-chair rank table
const pcrBody = $('pcr-body');
pcrBody.innerHTML = ['A','B','C'].map(ch => {
const d = data.per_chair_rank[ch] || {};
return `<tr>
<td class="chair-${ch.toLowerCase()}">${ch}</td>
<td class="num">${fmtFull(d.has_highest || 0)}</td>
<td class="num">${fmtFull(d.wins || 0)}</td>
<td class="num">${(d.win_pct || 0).toFixed(1)}%</td>
</tr>`;
}).join('');
// 4. Hand type distribution by chair (grouped bar chart)
const htByChair = data.hand_types_by_chair;
const htLabels = HAND_ORDER.filter(t =>
(htByChair.A[t] || 0) + (htByChair.B[t] || 0) + (htByChair.C[t] || 0) > 0
);
new Chart($('hand-type-chart'), {
type: 'bar',
data: {
labels: htLabels,
datasets: ['A','B','C'].map(ch => ({
label: ch,
data: htLabels.map(t => htByChair[ch][t] || 0),
backgroundColor: CHAIR_COLORS[ch] + '80',
borderColor: CHAIR_COLORS[ch],
borderWidth: 1,
})),
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } } },
scales: {
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314820' } },
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d314830' } },
}
}
});
// 5. Hand type wins
const htw = data.hand_type_wins;
const htwItems = HAND_ORDER.filter(t => htw[t]).map(t => ({label: t, value: htw[t]}));
renderHBar($('hand-type-wins-bars'), htwItems, () => '#6c5ce7');
// 6. Pot size buckets
const pb = data.pot_buckets;
const ranges = pb._ranges || {};
const bucketOrder = ['small','medium','large','whale'];
$('pot-body').innerHTML = bucketOrder.map(b => {
const d = pb[b];
if (!d) return '';
const t = d.total || 1;
return `<tr>
<td style="font-weight:700;text-transform:capitalize">${b}</td>
<td style="color:var(--text3)">${ranges[b] || ''}</td>
<td class="num">${fmtFull(d.total)}</td>
<td class="num" style="color:var(--chair-a)">${(d.A/t*100).toFixed(1)}%</td>
<td class="num" style="color:var(--chair-b)">${(d.B/t*100).toFixed(1)}%</td>
<td class="num" style="color:var(--chair-c)">${(d.C/t*100).toFixed(1)}%</td>
</tr>`;
}).join('');
// 7. Streaks
const streaks = data.streaks;
$('streak-cards').innerHTML = ['A','B','C'].map(ch => {
const s = streaks[ch];
return `<div class="streak-card">
<div class="streak-card-label" style="color:${CHAIR_COLORS[ch]}">Chair ${ch}</div>
<div class="streak-card-value" style="color:${CHAIR_COLORS[ch]}">${s.max_streak}</div>
<div class="streak-card-sub">Max Streak</div>
<div style="font-size:18px;font-weight:700;margin-top:8px;color:${s.current_streak >= 3 ? 'var(--green)' : 'var(--text2)'}">${s.current_streak}</div>
<div class="streak-card-sub">Current Streak</div>
</div>`;
}).join('');
// 8. Hourly patterns
const hourly = data.hourly;
const hours = Object.keys(hourly).map(Number).sort((a,b) => a - b);
new Chart($('hourly-chart'), {
type: 'bar',
data: {
labels: hours.map(h => String(h).padStart(2, '0') + ':00'),
datasets: ['A','B','C'].map(ch => ({
label: ch,
data: hours.map(h => {
const d = hourly[String(h)];
return d ? (d[ch] / d.total * 100) : 0;
}),
backgroundColor: CHAIR_COLORS[ch] + '80',
borderColor: CHAIR_COLORS[ch],
borderWidth: 1,
})),
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: {
legend: { labels: { color: '#8b8fa3', font: { size: 10 }, boxWidth: 12 } },
tooltip: { callbacks: { label: ctx => ` ${ctx.dataset.label}: ${ctx.raw.toFixed(1)}%` } },
},
scales: {
x: { stacked: true, ticks: { color: '#5a5f75', font: { size: 9 } }, grid: { color: '#2d314820' } },
y: {
stacked: true, max: 100,
ticks: { color: '#5a5f75', callback: v => v + '%', font: { size: 10 } },
grid: { color: '#2d314830' },
},
}
}
});
// 9. Recent vs Overall
const rva = data.recent_vs_all;
renderComparePanel($('compare-all'), rva.all.dist, rva.all.total);
renderComparePanel($('compare-recent'), rva.recent.dist, rva.recent.total);
}
// Fetch and render
fetch('/api/patterns')
.then(r => r.json())
.then(data => {
if (data.error) {
$('loading').textContent = 'Error: ' + data.error;
return;
}
$('loading').style.display = 'none';
$('main').style.display = 'block';
render(data);
})
.catch(e => {
$('loading').textContent = 'Failed to load: ' + e.message;
});
</script>
</body>
</html>