Compare commits
17 Commits
3eba3d77bc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c7cd67ab7 | |||
| 9762c0f9bf | |||
| 86865166ef | |||
| 5fd4894599 | |||
| d1dc8f62fa | |||
| 54501260b4 | |||
| e84145905f | |||
| 949d0c2a57 | |||
| 1eed8786db | |||
| 4903b6943a | |||
| b07b073cc0 | |||
| d8ec792a88 | |||
| 2b8e3dd456 | |||
| e65b6b2cfb | |||
| 3016f33783 | |||
| 9239fd7a05 | |||
| 46ffb3b61d |
166
analyze.py
Executable file
166
analyze.py
Executable 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()
|
||||||
@@ -33,7 +33,7 @@ VALUES = {1: "A", 2: "2", 3: "3", 4: "4", 5: "5", 6: "6", 7: "7",
|
|||||||
8: "8", 9: "9", 10: "10", 11: "J", 12: "Q", 13: "K", 14: "A"}
|
8: "8", 9: "9", 10: "10", 11: "J", 12: "Q", 13: "K", 14: "A"}
|
||||||
HAND_TYPES = {1: "High Card", 2: "Pair", 3: "Flush", 4: "Straight",
|
HAND_TYPES = {1: "High Card", 2: "Pair", 3: "Flush", 4: "Straight",
|
||||||
5: "Straight Flush", 6: "Trail"}
|
5: "Straight Flush", 6: "Trail"}
|
||||||
CHAIRS = {1: "A", 2: "B", 3: "C"}
|
CHAIRS = {1: "C", 2: "B", 3: "A"}
|
||||||
STATUS_NAMES = {0: "NEW", 1: "BETTING", 2: "REVEALING", 3: "ENDED"}
|
STATUS_NAMES = {0: "NEW", 1: "BETTING", 2: "REVEALING", 3: "ENDED"}
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
|
|||||||
@@ -152,6 +152,8 @@ class GamePoller:
|
|||||||
start_ts = self.round_data.get("time_start_ts", 0)
|
start_ts = self.round_data.get("time_start_ts", 0)
|
||||||
self.round_data["duration_s"] = round(time.time() - start_ts)
|
self.round_data["duration_s"] = round(time.time() - start_ts)
|
||||||
|
|
||||||
|
self._save_round()
|
||||||
|
|
||||||
await self.broadcast("round_result", {
|
await self.broadcast("round_result", {
|
||||||
"game_no": gn,
|
"game_no": gn,
|
||||||
"winner": gi.get("gameResult"),
|
"winner": gi.get("gameResult"),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import signal
|
|||||||
from .server import WebServer
|
from .server import WebServer
|
||||||
from .streamkar_ws import StreamKarWSClient
|
from .streamkar_ws import StreamKarWSClient
|
||||||
from .game_poller import GamePoller
|
from .game_poller import GamePoller
|
||||||
|
from . import db
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -39,6 +40,7 @@ async def main():
|
|||||||
loop.add_signal_handler(sig, shutdown)
|
loop.add_signal_handler(sig, shutdown)
|
||||||
|
|
||||||
log.info("Starting Teen Patti Live Monitor")
|
log.info("Starting Teen Patti Live Monitor")
|
||||||
|
await loop.run_in_executor(None, db.run_migrations)
|
||||||
log.info("Dashboard: http://localhost:8765")
|
log.info("Dashboard: http://localhost:8765")
|
||||||
|
|
||||||
tasks = [
|
tasks = [
|
||||||
|
|||||||
146
app/server.py
146
app/server.py
@@ -5,6 +5,7 @@ All blocking DB calls run in a thread executor to avoid blocking the event loop.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -30,7 +31,89 @@ class WebServer:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.app = web.Application()
|
self.app = web.Application()
|
||||||
self.clients: set[web.WebSocketResponse] = set()
|
self.clients: set[web.WebSocketResponse] = set()
|
||||||
|
self._visitor_buffer: list[dict] = []
|
||||||
|
self._visitor_lock = asyncio.Lock()
|
||||||
self._setup_routes()
|
self._setup_routes()
|
||||||
|
self.app.middlewares.append(self._make_visitor_middleware())
|
||||||
|
|
||||||
|
def _make_visitor_middleware(self):
|
||||||
|
server = self
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def visitor_middleware(request: web.Request, handler):
|
||||||
|
response = await handler(request)
|
||||||
|
path = request.path
|
||||||
|
# Skip static files and WebSocket upgrades
|
||||||
|
if path.startswith("/static/") or request.headers.get("Upgrade", "").lower() == "websocket":
|
||||||
|
return response
|
||||||
|
ip = (
|
||||||
|
request.headers.get("CF-Connecting-IP")
|
||||||
|
or (request.headers.get("X-Forwarded-For", "").split(",")[0].strip())
|
||||||
|
or request.remote
|
||||||
|
or ""
|
||||||
|
)
|
||||||
|
log.info('%s %s %s %d "%s"', ip, request.method, path, response.status,
|
||||||
|
request.headers.get("User-Agent", "-"))
|
||||||
|
visitor = {
|
||||||
|
"ip": ip,
|
||||||
|
"country": request.headers.get("CF-IPCountry", ""),
|
||||||
|
"path": path,
|
||||||
|
"method": request.method,
|
||||||
|
"user_agent": request.headers.get("User-Agent", ""),
|
||||||
|
"referer": request.headers.get("Referer", ""),
|
||||||
|
"accept_lang": request.headers.get("Accept-Language", ""),
|
||||||
|
}
|
||||||
|
batch = None
|
||||||
|
async with server._visitor_lock:
|
||||||
|
server._visitor_buffer.append(visitor)
|
||||||
|
if len(server._visitor_buffer) >= 20:
|
||||||
|
batch = server._visitor_buffer[:]
|
||||||
|
server._visitor_buffer.clear()
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
await _run_sync(db.insert_visitors, batch)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Visitor insert failed: %s", e)
|
||||||
|
return response
|
||||||
|
|
||||||
|
return visitor_middleware
|
||||||
|
|
||||||
|
async def _flush_visitors(self):
|
||||||
|
"""Periodically flush visitor buffer so low-traffic visits aren't lost."""
|
||||||
|
while True:
|
||||||
|
await asyncio.sleep(30)
|
||||||
|
batch = None
|
||||||
|
async with self._visitor_lock:
|
||||||
|
if self._visitor_buffer:
|
||||||
|
batch = self._visitor_buffer[:]
|
||||||
|
self._visitor_buffer.clear()
|
||||||
|
if batch:
|
||||||
|
try:
|
||||||
|
await _run_sync(db.insert_visitors, batch)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Visitor flush failed: %s", e)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_basic_auth(request: web.Request) -> bool:
|
||||||
|
auth = request.headers.get("Authorization", "")
|
||||||
|
if not auth.startswith("Basic "):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(auth[6:]).decode()
|
||||||
|
return decoded == "sk:hakunamatata2020"
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _require_auth(request: web.Request) -> web.Response | None:
|
||||||
|
"""Return a 401 response if auth fails, else None."""
|
||||||
|
if WebServer._check_basic_auth(request):
|
||||||
|
return None
|
||||||
|
return web.Response(
|
||||||
|
status=401,
|
||||||
|
headers={"WWW-Authenticate": 'Basic realm="3pmonitor"'},
|
||||||
|
text="Unauthorized",
|
||||||
|
)
|
||||||
|
|
||||||
def _setup_routes(self):
|
def _setup_routes(self):
|
||||||
self.app.router.add_get("/", self._handle_index)
|
self.app.router.add_get("/", self._handle_index)
|
||||||
@@ -41,6 +124,13 @@ 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("/predictions", self._handle_predictions_page)
|
||||||
|
self.app.router.add_get("/api/predictions", self._handle_predictions)
|
||||||
|
self.app.router.add_get("/api/prediction-history", self._handle_prediction_history)
|
||||||
|
self.app.router.add_get("/visitors", self._handle_visitors_page)
|
||||||
|
self.app.router.add_get("/api/visitors", self._handle_visitors)
|
||||||
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 +180,58 @@ 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_predictions_page(self, request: web.Request) -> web.Response:
|
||||||
|
path = os.path.join(STATIC_DIR, "predictions.html")
|
||||||
|
return web.FileResponse(path)
|
||||||
|
|
||||||
|
async def _handle_predictions(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await _run_sync(db.get_prediction_analysis)
|
||||||
|
return web.json_response(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Prediction analysis query failed: %s", e)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def _handle_prediction_history(self, request: web.Request) -> web.Response:
|
||||||
|
limit = min(int(request.query.get("limit", 100)), 500)
|
||||||
|
try:
|
||||||
|
data = await _run_sync(db.get_prediction_history, limit)
|
||||||
|
return web.json_response(data)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Prediction history query failed: %s", e)
|
||||||
|
return web.json_response({"error": str(e)}, status=500)
|
||||||
|
|
||||||
|
async def _handle_visitors_page(self, request: web.Request) -> web.Response:
|
||||||
|
denied = self._require_auth(request)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
path = os.path.join(STATIC_DIR, "visitors.html")
|
||||||
|
return web.FileResponse(path)
|
||||||
|
|
||||||
|
async def _handle_visitors(self, request: web.Request) -> web.Response:
|
||||||
|
denied = self._require_auth(request)
|
||||||
|
if denied:
|
||||||
|
return denied
|
||||||
|
limit = min(int(request.query.get("limit", 200)), 1000)
|
||||||
|
try:
|
||||||
|
visitors = await _run_sync(db.get_recent_visitors, limit)
|
||||||
|
return web.json_response(visitors)
|
||||||
|
except Exception as e:
|
||||||
|
log.error("Visitors query failed: %s", e)
|
||||||
|
return web.json_response([], 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"):
|
||||||
@@ -176,11 +318,12 @@ class WebServer:
|
|||||||
log.warning("push_refresh failed: %s", e)
|
log.warning("push_refresh failed: %s", e)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
runner = web.AppRunner(self.app)
|
runner = web.AppRunner(self.app, access_log=None)
|
||||||
await runner.setup()
|
await runner.setup()
|
||||||
site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT)
|
site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT)
|
||||||
await site.start()
|
await site.start()
|
||||||
log.info("Web server listening on http://0.0.0.0:%s", config.WEB_PORT)
|
log.info("Web server listening on http://0.0.0.0:%s", config.WEB_PORT)
|
||||||
|
flush_task = asyncio.create_task(self._flush_visitors())
|
||||||
# Keep running until cancelled
|
# Keep running until cancelled
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -188,4 +331,5 @@ class WebServer:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
|
flush_task.cancel()
|
||||||
await runner.cleanup()
|
await runner.cleanup()
|
||||||
|
|||||||
@@ -40,3 +40,16 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
updated_at DateTime DEFAULT now()
|
updated_at DateTime DEFAULT now()
|
||||||
) ENGINE = ReplacingMergeTree(updated_at)
|
) ENGINE = ReplacingMergeTree(updated_at)
|
||||||
ORDER BY user_id;
|
ORDER BY user_id;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS visitors (
|
||||||
|
ip String,
|
||||||
|
country String,
|
||||||
|
path String,
|
||||||
|
method String,
|
||||||
|
user_agent String,
|
||||||
|
referer String,
|
||||||
|
accept_lang String,
|
||||||
|
created_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
ORDER BY (created_at, ip)
|
||||||
|
TTL created_at + INTERVAL 90 DAY;
|
||||||
|
|||||||
@@ -212,7 +212,11 @@
|
|||||||
|
|
||||||
<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 →</a>
|
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||||
|
<a href="/patterns" class="nav-link">Patterns →</a>
|
||||||
|
<a href="/predictions" class="nav-link">Predictions →</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="period-bar">
|
<div class="period-bar">
|
||||||
@@ -333,7 +337,7 @@ const escHtml = s => {
|
|||||||
return d.innerHTML;
|
return d.innerHTML;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHAIRS = {1:'A', 2:'B', 3:'C'};
|
const CHAIRS = {1:'C', 2:'B', 3:'A'};
|
||||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
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'};
|
const HAND_TYPES = {1:'High Card', 2:'Pair', 3:'Flush', 4:'Straight', 5:'Str. Flush', 6:'Trail'};
|
||||||
|
|
||||||
|
|||||||
@@ -450,6 +450,7 @@
|
|||||||
max-height: 85vh; overflow-y: auto;
|
max-height: 85vh; overflow-y: auto;
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-close {
|
.modal-close {
|
||||||
float: right; background: none; border: none; color: var(--text2);
|
float: right; background: none; border: none; color: var(--text2);
|
||||||
font-size: 20px; cursor: pointer; padding: 0 4px;
|
font-size: 20px; cursor: pointer; padding: 0 4px;
|
||||||
@@ -502,6 +503,93 @@
|
|||||||
.modal-bets-table td { padding: 3px 6px; border-bottom: 1px solid #2d314830; }
|
.modal-bets-table td { padding: 3px 6px; border-bottom: 1px solid #2d314830; }
|
||||||
.bet-won { color: var(--green); }
|
.bet-won { color: var(--green); }
|
||||||
.bet-lost { color: var(--red); }
|
.bet-lost { color: var(--red); }
|
||||||
|
|
||||||
|
/* ── Mobile ── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-wrap: wrap; gap: 6px; padding: 8px 12px;
|
||||||
|
}
|
||||||
|
.header h1 { font-size: 13px; order: 1; }
|
||||||
|
.header .round-info { font-size: 11px; order: 2; }
|
||||||
|
.header .status { order: 3; width: 100%; justify-content: flex-end; }
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
.panel {
|
||||||
|
padding: 10px;
|
||||||
|
overflow-y: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer { font-size: 22px; }
|
||||||
|
.total-pot { font-size: 11px; }
|
||||||
|
.total-pot span { font-size: 13px; }
|
||||||
|
|
||||||
|
.chairs { gap: 4px; }
|
||||||
|
.chair { padding: 8px 4px; border-radius: 6px; }
|
||||||
|
.chair-bet { font-size: 14px; }
|
||||||
|
.chair-label { font-size: 11px; }
|
||||||
|
.chair-rank { font-size: 8px; }
|
||||||
|
.chair-predict { font-size: 7px; }
|
||||||
|
|
||||||
|
.top-bettors .tb-row { padding: 3px 4px; font-size: 11px; }
|
||||||
|
.tb-name { font-size: 11px; }
|
||||||
|
.tb-chip { font-size: 9px; padding: 1px 3px; }
|
||||||
|
.tb-total { font-size: 11px; min-width: 45px; }
|
||||||
|
|
||||||
|
.whale-trend-row { gap: 4px; font-size: 11px; }
|
||||||
|
.whale-trend-chair { font-size: 13px; min-width: 18px; }
|
||||||
|
.whale-trend-bar-bg { height: 16px; }
|
||||||
|
.whale-trend-pct { font-size: 11px; min-width: 34px; }
|
||||||
|
.whale-trend-amt { font-size: 9px; min-width: 40px; }
|
||||||
|
|
||||||
|
.bets-feed { max-height: 200px; font-size: 11px; }
|
||||||
|
.bet-user { max-width: 80px; font-size: 11px; }
|
||||||
|
.bet-amount { font-size: 11px; }
|
||||||
|
.bet-session { font-size: 9px; min-width: 40px; }
|
||||||
|
|
||||||
|
.lb-row { padding: 4px 2px; font-size: 11px; }
|
||||||
|
.lb-pnl { min-width: 50px; font-size: 11px; }
|
||||||
|
|
||||||
|
.hc-grid { grid-template-columns: 1fr; gap: 6px; }
|
||||||
|
.hc-player { font-size: 10px; }
|
||||||
|
.hc-name { font-size: 10px; }
|
||||||
|
|
||||||
|
.chart-container { height: 100px; }
|
||||||
|
|
||||||
|
.dist-row { gap: 4px; }
|
||||||
|
.dist-item { padding: 6px 2px; }
|
||||||
|
.dist-value { font-size: 16px; }
|
||||||
|
.dist-label { font-size: 9px; }
|
||||||
|
.dist-pct { font-size: 9px; }
|
||||||
|
|
||||||
|
.history-table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; }
|
||||||
|
.history-table { font-size: 10px; min-width: 500px; }
|
||||||
|
.history-table th, .history-table td { padding: 3px 3px; }
|
||||||
|
.hand-cards { font-size: 13px; }
|
||||||
|
.hand-tag { font-size: 8px; padding: 0px 3px; }
|
||||||
|
.history-hand { font-size: 10px; }
|
||||||
|
.history-pot { font-size: 8px; }
|
||||||
|
|
||||||
|
.biggest-winner { padding: 8px 10px; gap: 8px; }
|
||||||
|
.bw-crown { font-size: 16px; }
|
||||||
|
.bw-name { font-size: 12px; }
|
||||||
|
.bw-stats { font-size: 10px; }
|
||||||
|
.bw-pnl { font-size: 14px; }
|
||||||
|
|
||||||
|
.modal { width: 95vw; max-height: 90vh; padding: 14px; border-radius: 8px; }
|
||||||
|
.modal-stats { grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
||||||
|
.modal-stat { padding: 6px; }
|
||||||
|
.modal-stat-value { font-size: 14px; }
|
||||||
|
.modal-header { gap: 8px; }
|
||||||
|
.modal-name { font-size: 14px; }
|
||||||
|
.modal-avatar { width: 40px; height: 40px; }
|
||||||
|
.modal-bets-table { font-size: 10px; }
|
||||||
|
|
||||||
|
.panel-title { font-size: 9px; margin-bottom: 8px; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -514,6 +602,8 @@
|
|||||||
</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 →</a>
|
<a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics →</a>
|
||||||
|
<a href="/patterns" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Patterns →</a>
|
||||||
|
<a href="/predictions" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Predictions →</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>
|
||||||
@@ -646,7 +736,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel-title">History</div>
|
<div class="panel-title">History</div>
|
||||||
<div style="max-height:400px;overflow-y:auto">
|
<div class="history-table-wrap" style="max-height:400px;overflow-y:auto">
|
||||||
<table class="history-table">
|
<table class="history-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -687,7 +777,7 @@ const fmtFull = n => {
|
|||||||
return Number(n).toLocaleString();
|
return Number(n).toLocaleString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const CHAIRS = {1:'A', 2:'B', 3:'C'};
|
const CHAIRS = {1:'C', 2:'B', 3:'A'};
|
||||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
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'};
|
const HAND_TYPES = {1:'High Card', 2:'Pair', 3:'Flush', 4:'Straight', 5:'Str. Flush', 6:'Trail'};
|
||||||
const HAND_RANK = {1:1, 2:2, 3:3, 4:4, 5:5, 6:6};
|
const HAND_RANK = {1:1, 2:2, 3:3, 4:4, 5:5, 6:6};
|
||||||
|
|||||||
444
static/patterns.html
Normal file
444
static/patterns.html
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
<!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 →</a>
|
||||||
|
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||||
|
<a href="/predictions" class="nav-link">Predictions →</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>
|
||||||
1282
static/predictions.html
Normal file
1282
static/predictions.html
Normal file
File diff suppressed because it is too large
Load Diff
98
static/visitors.html
Normal file
98
static/visitors.html
Normal 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, '"')}">${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>
|
||||||
Reference in New Issue
Block a user