Compare commits

..

17 Commits

Author SHA1 Message Date
2c7cd67ab7 fix streak signal, reweight predictions, and reorder UI
- Fix streak signal: was giving 100% to streak chair after normalization
  (non-streak chairs were 0), now properly distributes probability with
  streak chair getting less as streak grows (actual mean reversion)
- Change recent window from 20 to 50 games
- Reweight signals based on backtest: base_rate 0.15→0.20 (best performer),
  recent 0.10→0.15, streak 0.10→0.05, balance 0.15→0.10
- Move Live Market Sentiment above Signal Breakdown
2026-02-26 10:36:53 +05:00
9762c0f9bf add bet impact simulator, visitor log page, and fix console logging
- Bet impact simulator on /predictions shows rank headroom and safe bet amounts
- Password-protected /visitors page with visitor log table and stats
- Console now logs real visitor IPs instead of Cloudflare tunnel IPs
2026-02-26 10:19:14 +05:00
86865166ef add balance/mean-reversion signal and Cloudflare visitor logging
Balance signal (15% weight) favors under-represented chairs over last 50
games. Visitor middleware captures real IPs from CF headers, batched into
ClickHouse with 90-day TTL.
2026-02-26 09:59:27 +05:00
5fd4894599 add whale/public semi-win scoring, expand to last 50, and full history modal
- Whale/public picks now track 2nd pick and score SEMI (0.5 pts) like model
- Prediction table expanded from 20 to 50 rows
- "View All History" modal with pagination (50/page), fetches up to 500
- Accuracy rows use semi-win scoring for all three columns
2026-02-26 09:51:08 +05:00
d1dc8f62fa add whale/public picks to prediction history and new API endpoint
- Add _compute_whale_public_picks() to reconstruct whale/public picks from historical bets
- Merge whale_pick, public_pick, whale_hit, public_hit into last_20_predictions
- Add get_prediction_history(limit) for lightweight prediction+accuracy data
- Add /api/prediction-history endpoint (default 100, max 500)
- Add Whale and Public columns with HIT/MISS to Last 20 table in frontend
2026-02-26 09:42:16 +05:00
54501260b4 add bettor count trend to public trend panel 2026-02-26 09:29:34 +05:00
e84145905f fix bet recommendations to show actual coin amounts on fav and 2nd pick 2026-02-26 00:09:21 +05:00
949d0c2a57 add semi-win scoring (0.5 pts for 2nd pick) and whale/public bet recommendations 2026-02-26 00:03:59 +05:00
1eed8786db add seat recommendations, bet advisor, and live whale/public trends
Prediction hero now shows ranked TOP PICK and 2ND PICK with EV per unit
bet (P(win)*2.9 - 1). Bet Size Advisor panel shows Kelly criterion
fraction (capped 25%), best chair with confidence, and historical bet
rank insight (how often lowest/highest-bet chair wins).

Live Market Sentiment section tracks whale trend (top 5 bettors by amount)
and public trend (total pool distribution) in real-time via WebSocket,
mirroring the live dashboard. Notes highlight agreement/divergence between
model pick and crowd favorite.

Historical crowd analysis cards show how often the most-bet, mid-bet, and
least-bet chairs actually won across all games.

Round result flash now includes whale/public pick accuracy alongside the
model prediction result. user_bet WebSocket events are tracked to build
per-round bettor profiles for whale analysis.
2026-02-25 23:41:40 +05:00
4903b6943a add real-time game data, prediction history, and fix winning cards chart
- WebSocket connection shows live game state (round #, phase, bets per
  chair, pot) in a persistent bar at the top of predictions page
- Prediction cards now display current bet amounts per chair
- Round results flash HIT/MISS against the Bayesian prediction
- New "Last 20 Predictions vs Actual" table with per-game probabilities,
  predicted vs actual winner, and running accuracy
- Predictions auto-refresh after each round ends
- Fix winning cards chart: use taller container (480px) and dedicated
  scales config for horizontal bar rendering
- Add _last_n_predictions() helper to db.py for detailed per-game
  prediction history with game numbers
2026-02-25 23:25:49 +05:00
b07b073cc0 add predictions page with game theory analysis and card stats
Bayesian next-chair predictor (Markov chains, base rate, streak regression),
statistical tests (chi-squared, runs test, autocorrelation), theory
backtesting with rolling accuracy, and card-level analysis (value/suit
distribution, face card frequency, top winning cards).
2026-02-25 23:16:37 +05:00
d8ec792a88 fix UNION ALL ORDER BY using positional references for ClickHouse 2026-02-25 22:56:53 +05:00
2b8e3dd456 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.
2026-02-25 22:45:43 +05:00
e65b6b2cfb fix reversed A/C chair mapping and update hot/cold on round end
CHAIRS mapping was {1:A, 2:B, 3:C} but the API's country field has
1=C and 3=A. Fixed the mapping in backend and both frontends, added a
startup migration to swap A↔C columns in existing DB data, corrected
multiIf SQL queries that hardcoded the wrong winner→column mapping,
and moved _save_round() to the ENDED status block so hot/cold stats
reflect the latest round immediately.
2026-02-25 20:49:16 +05:00
3016f33783 mobile support fixes 2026-02-22 20:43:31 +05:00
9239fd7a05 mobile support 2026-02-22 20:41:47 +05:00
46ffb3b61d mobile support 2026-02-22 20:36:02 +05:00
12 changed files with 3276 additions and 9 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()

View File

@@ -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

1028
app/db.py

File diff suppressed because it is too large Load Diff

View File

@@ -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"),

View File

@@ -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 = [

View File

@@ -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()

View File

@@ -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;

View File

@@ -212,7 +212,11 @@
<div class="header"> <div class="header">
<h1>Teen Patti Analytics</h1> <h1>Teen Patti Analytics</h1>
<a href="/" class="nav-link">Live Dashboard &rarr;</a> <div style="display:flex;gap:14px">
<a href="/" class="nav-link">Live Dashboard &rarr;</a>
<a href="/patterns" class="nav-link">Patterns &rarr;</a>
<a href="/predictions" class="nav-link">Predictions &rarr;</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'};

View File

@@ -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 &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>
<a href="/predictions" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Predictions &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>
@@ -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
View 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 &rarr;</a>
<a href="/analytics" class="nav-link">Analytics &rarr;</a>
<a href="/predictions" class="nav-link">Predictions &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>

1282
static/predictions.html Normal file

File diff suppressed because it is too large Load Diff

98
static/visitors.html Normal file
View File

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