Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
652a5aa66e | ||
|
|
7be89e70c8 | ||
|
|
28bb045274 | ||
|
|
7886b3a6b8 | ||
|
|
c4800fca0e | ||
|
|
49ac0d3e78 | ||
|
|
3615fa2bb1 | ||
|
|
7a5202195b |
@@ -12,6 +12,11 @@ CORS_ALLOWED_ORIGINS=https://bridzik.liptim.eu
|
||||
# Shared secret for the self-hosted /api/admin/* stats endpoints.
|
||||
ADMIN_TOKEN=tajneheslo
|
||||
|
||||
# pgAdmin login (UI bound to 127.0.0.1:8081 on the host -- access via SSH tunnel,
|
||||
# never expose publicly since it has full access to the Postgres data).
|
||||
PGADMIN_DEFAULT_EMAIL=admin@example.com
|
||||
PGADMIN_DEFAULT_PASSWORD=zmen_ma
|
||||
|
||||
# Fernet key encrypting Player.totp_secret at rest. Generate with:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Keep it stable across restarts/redeploys -- losing it locks every account
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# TASKS.md
|
||||
|
||||
Nápady a úlohy pre bridge hru. Nový nápad pridaj ako riadok do "Nedokončené" (cez mobil/Dispatch alebo priamo).
|
||||
Hodinová routine v Claude Code číta túto sekciu, implementuje a presúva hotové úlohy dole.
|
||||
|
||||
## Nedokončené
|
||||
|
||||
## Rozpracované
|
||||
|
||||
<!-- Sem si routine presunie úlohu, na ktorej práve pracuje, aby sa pri ďalšom behu neduplikovala. -->
|
||||
|
||||
## Hotovo
|
||||
|
||||
- [x] V lobby hre sa dá skopírovať ID hry. Zmeň to tak, aby sa namiesto ID kopírovala celá URL linka na hru. — 2026-07-03: tlačidlo "Kopírovať" v Lobby.tsx teraz kopíruje `${window.location.origin}/lobby/${gid}` namiesto holého gid. Commit 49ac0d3.
|
||||
|
||||
<!-- Sem routine presunie dokončené úlohy s dátumom a krátkym popisom + commit hashom. -->
|
||||
+45
-6
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
@@ -5,6 +6,7 @@ import time
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from json import JSONEncoder
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
import socketio
|
||||
|
||||
@@ -102,6 +104,9 @@ async def _handle_track(scope, receive, send):
|
||||
data = {}
|
||||
headers = dict(scope.get("headers") or [])
|
||||
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
|
||||
# player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na
|
||||
# neautentifikovanom endpointe) -- pripaja sa len server-side na evente
|
||||
# "login" (pozri handler @sio.on("login")).
|
||||
await stats_module.record_pageview(
|
||||
path=str(data.get("path", ""))[:200],
|
||||
referrer=str(data.get("referrer", ""))[:300],
|
||||
@@ -119,7 +124,9 @@ async def _handle_admin_stats(scope, send):
|
||||
if not _admin_authorized(scope):
|
||||
_register_admin_failure(ip)
|
||||
return await _send_text(send, 403, b"forbidden")
|
||||
data = await stats_module.get_daily_stats()
|
||||
query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore"))
|
||||
logged_in_only = query.get("logged_in", ["0"])[0] == "1"
|
||||
data = await stats_module.get_daily_stats(logged_in_only=logged_in_only)
|
||||
await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
|
||||
|
||||
|
||||
@@ -273,14 +280,32 @@ async def send_error(sid: str, message: str):
|
||||
await sio.emit("error", {"error": message}, to=sid)
|
||||
|
||||
|
||||
# Ako dlho prezije nezacata hra, ked su vsetci hraci naraz offline. Na mobile
|
||||
# sa socket bezne strati uz pri zamknuti obrazovky, takze okamzite zmazanie
|
||||
# hry rusilo lobby, v ktorom hraci len cakali so zhasnutym telefonom.
|
||||
LOBBY_ABANDON_GRACE_SECONDS = 10 * 60
|
||||
|
||||
|
||||
async def _cleanup_abandoned_lobby(gid: str):
|
||||
"""Po grace periode zmaz nezacatu hru, ak sa medzitym nikto nevratil.
|
||||
Podmienka sa overuje az po uplynuti casu, takze pri navrate hraca je
|
||||
task neskodny no-op (netreba nic rusit)."""
|
||||
await asyncio.sleep(LOBBY_ABANDON_GRACE_SECONDS)
|
||||
game = games.get(gid)
|
||||
if game is not None and not game.started and not any(p.connected for p in game.players):
|
||||
del games[gid]
|
||||
await broadcast_lobby()
|
||||
|
||||
|
||||
async def _mark_player_offline(game: "Game", player: "Player"):
|
||||
"""Mark player disconnected. An unstarted game with nobody left is cleaned
|
||||
up; a started game is kept in memory so it stays in the lobby and can be
|
||||
resumed (it's torn down only by end_game)."""
|
||||
"""Mark player disconnected. An unstarted game with nobody left gets a
|
||||
delayed cleanup (mobile sockets drop on screen lock, so an immediate
|
||||
delete would kill lobbies where everyone is just waiting); a started game
|
||||
is kept in memory so it stays in the lobby and can be resumed (it's torn
|
||||
down only by end_game)."""
|
||||
player.connected = False
|
||||
if not any(p.connected for p in game.players) and not game.started:
|
||||
del games[game.gid]
|
||||
else:
|
||||
asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
|
||||
await sio.emit(
|
||||
"player_connection",
|
||||
{"order": player.order, "connected": False},
|
||||
@@ -363,6 +388,18 @@ async def register_account(sid, username):
|
||||
await sio.emit("register_account", data, to=sid)
|
||||
|
||||
|
||||
async def _record_login_event(sid: str, player_id: int) -> None:
|
||||
scope = (sio.get_environ(sid) or {}).get("asgi.scope", {})
|
||||
headers = dict(scope.get("headers") or [])
|
||||
await stats_module.record_pageview(
|
||||
path="login",
|
||||
referrer="",
|
||||
user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300],
|
||||
ip=_client_ip(scope),
|
||||
player_id=player_id,
|
||||
)
|
||||
|
||||
|
||||
@sio.on("confirm_account")
|
||||
async def confirm_account(sid, username, code):
|
||||
try:
|
||||
@@ -370,6 +407,7 @@ async def confirm_account(sid, username, code):
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await _record_login_event(sid, identity["player_id"])
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
|
||||
|
||||
@@ -380,6 +418,7 @@ async def login(sid, username, code):
|
||||
except AuthError as exc:
|
||||
return await send_error(sid, str(exc))
|
||||
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
|
||||
await _record_login_event(sid, identity["player_id"])
|
||||
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
|
||||
|
||||
|
||||
|
||||
+164
-63
@@ -19,8 +19,9 @@ _geoip_load_attempted = False
|
||||
|
||||
|
||||
def _country_for_ip(ip: str) -> str:
|
||||
"""ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je
|
||||
dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.)."""
|
||||
"""Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
|
||||
ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
|
||||
adresa a pod.)."""
|
||||
global _geoip_reader, _geoip_load_attempted
|
||||
if not ip:
|
||||
return ""
|
||||
@@ -33,12 +34,36 @@ def _country_for_ip(ip: str) -> str:
|
||||
return ""
|
||||
_geoip_reader = geoip2.database.Reader(path)
|
||||
try:
|
||||
return _geoip_reader.country(ip).country.iso_code or ""
|
||||
return _geoip_reader.country(ip).country.name or ""
|
||||
except (geoip2.errors.AddressNotFoundError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "") -> None:
|
||||
# Cesty s dynamickym ID segmentom -- do statistik sa uklada len prefix, aby sa
|
||||
# navstevy neroztriestili na /lobby/<gid>, /game/<gid>... (kazda hra inak max 4x).
|
||||
_DYNAMIC_PATH_PREFIXES = ("/lobby", "/game")
|
||||
|
||||
# Tieto (po normalizacii) sa vobec nezaznamenavaju -- vysoka frekvencia (kazda
|
||||
# akcia v hre) bez analytickej hodnoty. Ostava len /auth, /history a pomenovane
|
||||
# eventy (napr. "rules_view", "login"), ktore sem nespadaju.
|
||||
_SKIPPED_PATHS = frozenset({"/", "/lobby", "/game"})
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
for prefix in _DYNAMIC_PATH_PREFIXES:
|
||||
if path == prefix or path.startswith(prefix + "/"):
|
||||
return prefix
|
||||
return path
|
||||
|
||||
|
||||
async def record_pageview(
|
||||
path: str, referrer: str, user_agent: str, ip: str = "", player_id: int | None = None
|
||||
) -> None:
|
||||
"""Zapise navstevu URL cesty ALEBO pomenovany event -- rovnaky stlpec `path`
|
||||
rozlisuje oboje podla toho, ci zacina "/" (pozri PageView.path)."""
|
||||
normalized = _normalize_path(path)
|
||||
if normalized in _SKIPPED_PATHS:
|
||||
return
|
||||
ua = parse_ua(user_agent)
|
||||
device_type = (
|
||||
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
|
||||
@@ -46,7 +71,8 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "
|
||||
async with async_session() as session:
|
||||
session.add(
|
||||
PageView(
|
||||
path=path,
|
||||
path=normalized,
|
||||
player_id=player_id,
|
||||
referrer=referrer,
|
||||
user_agent=user_agent,
|
||||
browser=ua.browser.family[:40],
|
||||
@@ -61,35 +87,106 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "
|
||||
|
||||
_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno
|
||||
|
||||
# Identita navstevnika = IP + User-Agent (rovnaky pristup ako Plausible/
|
||||
# GoatCounter): odlisi dvoch ludi za jednym NAT-om s roznym prehliadacom/
|
||||
# zariadenim. Dvoch s uplne identickym UA neodlisi nic bez cookies.
|
||||
_visitor_id = PageView.ip + "|" + PageView.user_agent
|
||||
|
||||
async def _pageviews_by_day_and(session, column) -> dict[str, dict[str, int]]:
|
||||
|
||||
async def _pageviews_by_day_and(session, column, logged_in_only=False) -> dict[str, dict[str, int]]:
|
||||
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
|
||||
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
|
||||
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
|
||||
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu."""
|
||||
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu.
|
||||
|
||||
Scope "logged_in": kazdy login sa pocita samostatne (rovnako ako
|
||||
_login_event_counts), aby graf sedel s cislami v BreakdownTable nizsie.
|
||||
Scope "all": pocita sa "navstevnicky den" (rovnako ako _daily_unique_by),
|
||||
z toho isteho dovodu -- inak by graf (klikova statistika) nesedel s
|
||||
cislami dole (navstevnicka statistika)."""
|
||||
vday = func.date(PageView.created_at)
|
||||
recent_days = (
|
||||
select(vday.label("day"))
|
||||
.distinct()
|
||||
.order_by(vday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
.subquery()
|
||||
if logged_in_only:
|
||||
recent_days_q = select(vday.label("day")).distinct().where(PageView.path == "login")
|
||||
rows_q = (
|
||||
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
||||
.where(PageView.path == "login")
|
||||
)
|
||||
recent_days = recent_days_q.order_by(vday.desc()).limit(_DAYS_WINDOW).subquery()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(vday.label("day"), column.label("cat"), func.count().label("n"))
|
||||
.where(vday.in_(select(recent_days.c.day)))
|
||||
rows_q.where(vday.in_(select(recent_days.c.day)))
|
||||
.group_by(vday, column)
|
||||
.order_by(vday.desc())
|
||||
)
|
||||
).all()
|
||||
else:
|
||||
inner = select(
|
||||
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
||||
).distinct().subquery()
|
||||
recent_days = (
|
||||
select(inner.c.day).distinct().order_by(inner.c.day.desc()).limit(_DAYS_WINDOW)
|
||||
).subquery()
|
||||
rows = (
|
||||
await session.execute(
|
||||
select(inner.c.day, inner.c.cat, func.count().label("n"))
|
||||
.where(inner.c.day.in_(select(recent_days.c.day)))
|
||||
.group_by(inner.c.day, inner.c.cat)
|
||||
.order_by(inner.c.day.desc())
|
||||
)
|
||||
).all()
|
||||
nested: dict[str, dict[str, int]] = {}
|
||||
for r in rows:
|
||||
nested.setdefault(str(r.day), {})[r.cat] = r.n
|
||||
return nested
|
||||
|
||||
|
||||
async def get_daily_stats() -> dict:
|
||||
async def _daily_unique_by(session, column, exclude_empty=False) -> list:
|
||||
"""Rozklad podla dimenzie (browser/os/...) pre anonymnu navstevnost, kde
|
||||
jednotka nie je klik ale "navstevnicky den": ten isty navstevnik (IP+UA)
|
||||
sa v ramci jedneho dna pocita raz, na dalsi den znova. Sedi tak so suctom
|
||||
grafu visitors_per_day. Pre scope "logged_in" sa nepouziva -- tam ma kazde
|
||||
prihlasenie vahu 1x (viz _login_event_counts), aby to sedelo s
|
||||
pageviews_per_day ("Ked sa 2x prihlasi ten isty user, chcem to mat ako 2x").
|
||||
|
||||
Portable cez SQLite aj Postgres: najprv DISTINCT (den, kategoria, navstevnik)
|
||||
v subquery, potom GROUP BY kategoria."""
|
||||
vday = func.date(PageView.created_at)
|
||||
inner = select(
|
||||
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
|
||||
).distinct()
|
||||
if exclude_empty:
|
||||
inner = inner.where(column != "")
|
||||
sub = inner.subquery()
|
||||
return (
|
||||
await session.execute(
|
||||
select(sub.c.cat, func.count().label("n"))
|
||||
.group_by(sub.c.cat)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
|
||||
|
||||
async def _login_event_counts(session, column, exclude_empty=False) -> list:
|
||||
"""Rozklad podla dimenzie pocitany priamo z poctu login-eventov (kazdy
|
||||
riadok PageView s path == "login" sa pocita samostatne) -- na rozdiel od
|
||||
_daily_unique_by nededuplikuje podla navstevnika/dna, takze opakovane
|
||||
prihlasenie toho isteho hraca v ten isty den sa prejavi ako 2, presne
|
||||
ako v pageviews_per_day."""
|
||||
q = select(column.label("cat"), func.count().label("n")).where(PageView.path == "login")
|
||||
if exclude_empty:
|
||||
q = q.where(column != "")
|
||||
return (
|
||||
await session.execute(q.group_by(column).order_by(func.count().desc()))
|
||||
).all()
|
||||
|
||||
|
||||
async def get_daily_stats(logged_in_only: bool = False) -> dict:
|
||||
"""logged_in_only obmedzuje traffic-analyticke widgety (PageView) na
|
||||
zaznamy z eventu "login" (jediny event, ktory nesie player_id -- bezne
|
||||
beacony ho neposielaju vobec). Herne metriky (games/players/rounds) su
|
||||
uz zo svojej podstaty vzdy o prihlasenych uctoch, prepinac sa ich netyka.
|
||||
top_paths ostava vzdy pocitane zo vsetkych navstev bez ohladu na scope --
|
||||
najnavstevovanejsie stranky maju zmysel len ako celok."""
|
||||
async with async_session() as session:
|
||||
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
|
||||
# across SQLite and Postgres and returns a plain string/date value without
|
||||
@@ -151,23 +248,31 @@ async def get_daily_stats() -> dict:
|
||||
).all()
|
||||
|
||||
vday = func.date(PageView.created_at)
|
||||
pageview_q = select(vday.label("day"), func.count().label("n"))
|
||||
if logged_in_only:
|
||||
# "Navstevnici" v scope Prihlaseni = unikatni HRACI (player_id) za
|
||||
# den, nie unikatne IP+UA -- to je presny pocet skutocnych uctov.
|
||||
pageview_q = pageview_q.where(PageView.path == "login")
|
||||
visitor_q = (
|
||||
select(vday.label("day"), func.count(func.distinct(PageView.player_id)).label("n"))
|
||||
.where(PageView.path == "login")
|
||||
)
|
||||
else:
|
||||
visitor_q = select(vday.label("day"), func.count(func.distinct(_visitor_id)).label("n"))
|
||||
|
||||
pageview_rows = (
|
||||
await session.execute(
|
||||
select(vday.label("day"), func.count().label("n"))
|
||||
.group_by(vday)
|
||||
.order_by(vday.desc())
|
||||
.limit(_DAYS_WINDOW)
|
||||
pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
top_referrers = (
|
||||
# Unikatni navstevnici za den (distinct IP+UA v ramci dna; ten isty
|
||||
# navstevnik sa na dalsi den pocita znova).
|
||||
visitor_rows = (
|
||||
await session.execute(
|
||||
select(PageView.referrer, func.count().label("n"))
|
||||
.where(PageView.referrer != "")
|
||||
.group_by(PageView.referrer)
|
||||
.order_by(func.count().desc())
|
||||
.limit(20)
|
||||
visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
|
||||
)
|
||||
).all()
|
||||
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
|
||||
top_paths = (
|
||||
await session.execute(
|
||||
select(PageView.path, func.count().label("n"))
|
||||
@@ -177,39 +282,34 @@ async def get_daily_stats() -> dict:
|
||||
)
|
||||
).all()
|
||||
|
||||
browsers = (
|
||||
await session.execute(
|
||||
select(PageView.browser, func.count().label("n"))
|
||||
.group_by(PageView.browser)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
os_rows = (
|
||||
await session.execute(
|
||||
select(PageView.os, func.count().label("n"))
|
||||
.group_by(PageView.os)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
device_rows = (
|
||||
await session.execute(
|
||||
select(PageView.device_type, func.count().label("n"))
|
||||
.group_by(PageView.device_type)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
country_rows = (
|
||||
await session.execute(
|
||||
select(PageView.country, func.count().label("n"))
|
||||
.where(PageView.country != "")
|
||||
.group_by(PageView.country)
|
||||
.order_by(func.count().desc())
|
||||
)
|
||||
).all()
|
||||
if logged_in_only:
|
||||
# Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
|
||||
# pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
|
||||
# v ten isty den ma tiez pridat 2, nie 1.
|
||||
top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
|
||||
browsers = await _login_event_counts(session, PageView.browser)
|
||||
os_rows = await _login_event_counts(session, PageView.os)
|
||||
device_rows = await _login_event_counts(session, PageView.device_type)
|
||||
country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
|
||||
else:
|
||||
# Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
|
||||
# dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
|
||||
# neprevazi tabulky.
|
||||
top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
|
||||
browsers = await _daily_unique_by(session, PageView.browser)
|
||||
os_rows = await _daily_unique_by(session, PageView.os)
|
||||
device_rows = await _daily_unique_by(session, PageView.device_type)
|
||||
country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
|
||||
|
||||
pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type)
|
||||
pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser)
|
||||
pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os)
|
||||
pageviews_per_day_by_device = await _pageviews_by_day_and(
|
||||
session, PageView.device_type, logged_in_only=logged_in_only
|
||||
)
|
||||
pageviews_per_day_by_browser = await _pageviews_by_day_and(
|
||||
session, PageView.browser, logged_in_only=logged_in_only
|
||||
)
|
||||
pageviews_per_day_by_os = await _pageviews_by_day_and(
|
||||
session, PageView.os, logged_in_only=logged_in_only
|
||||
)
|
||||
|
||||
return {
|
||||
"games_per_day": {str(r.day): r.n for r in game_rows},
|
||||
@@ -220,13 +320,14 @@ async def get_daily_stats() -> dict:
|
||||
"peak_hours": {int(r.h): r.n for r in peak_hours},
|
||||
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
|
||||
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
|
||||
"visitors_per_day": {str(r.day): r.n for r in visitor_rows},
|
||||
"pageviews_per_day_by_device": pageviews_per_day_by_device,
|
||||
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
|
||||
"pageviews_per_day_by_os": pageviews_per_day_by_os,
|
||||
"top_referrers": {r.referrer: r.n for r in top_referrers},
|
||||
"top_referrers": {r.cat: r.n for r in top_referrers},
|
||||
"top_paths": {r.path: r.n for r in top_paths},
|
||||
"browsers": {r.browser: r.n for r in browsers},
|
||||
"operating_systems": {r.os: r.n for r in os_rows},
|
||||
"device_types": {r.device_type: r.n for r in device_rows},
|
||||
"countries": {r.country: r.n for r in country_rows},
|
||||
"browsers": {r.cat: r.n for r in browsers},
|
||||
"operating_systems": {r.cat: r.n for r in os_rows},
|
||||
"device_types": {r.cat: r.n for r in device_rows},
|
||||
"countries": {r.cat: r.n for r in country_rows},
|
||||
}
|
||||
|
||||
+7
-1
@@ -93,14 +93,20 @@ class PageView(Base):
|
||||
__tablename__ = "page_views"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
# Konvencia: zacina "/" -> skutocna URL cesta; inak nazov eventu (napr.
|
||||
# "rules_view", "login") -- ziadny osobitny stlpec, rovnaky stlpec pre oboje.
|
||||
path: Mapped[str] = mapped_column(String(200))
|
||||
# Vyplnene len ak je znamy prihlaseny hrac v momente zapisu (napr. event "login").
|
||||
player_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("players.id"), nullable=True, index=True
|
||||
)
|
||||
referrer: Mapped[str] = mapped_column(String(300), default="")
|
||||
user_agent: Mapped[str] = mapped_column(String(300), default="")
|
||||
browser: Mapped[str] = mapped_column(String(40), default="")
|
||||
os: Mapped[str] = mapped_column(String(40), default="")
|
||||
device_type: Mapped[str] = mapped_column(String(20), default="") # mobile/tablet/pc/bot
|
||||
ip: Mapped[str] = mapped_column(String(45), default="") # surova IP (IPv4/IPv6), "" ak nezname
|
||||
country: Mapped[str] = mapped_column(String(2), default="") # ISO kod z GeoIP, "" ak nerozlusene
|
||||
country: Mapped[str] = mapped_column(String(60), default="") # anglicky nazov z GeoIP, "" ak nerozlusene
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now()
|
||||
)
|
||||
|
||||
@@ -15,6 +15,21 @@ services:
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql
|
||||
restart: always
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
pgadmin:
|
||||
image: dpage/pgadmin4:latest
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
backend:
|
||||
build: .
|
||||
@@ -29,14 +44,24 @@ services:
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
frontend:
|
||||
container_name: bridzik
|
||||
build: ./frontend
|
||||
environment:
|
||||
BACKEND_UPSTREAM: backend:5000
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
caddy_network:
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
pgadmin_data:
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
+18
-9
@@ -1,15 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { lazy, Suspense, useEffect } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
|
||||
import { useGameStore } from './store/gameStore';
|
||||
import { socket, emit } from './lib/socket';
|
||||
import { trackEvent } from './lib/track';
|
||||
import type { MyPlayer } from './types';
|
||||
import GameList from './pages/GameList';
|
||||
import Lobby from './pages/Lobby';
|
||||
import GameTable from './pages/GameTable';
|
||||
import Auth from './pages/Auth';
|
||||
import History from './pages/History';
|
||||
import AdminLayout from './pages/admin/AdminLayout';
|
||||
import AdminStats from './pages/admin/AdminStats';
|
||||
|
||||
// Admin pulls in recharts — lazy-load it so players never download that chunk.
|
||||
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
|
||||
const AdminStats = lazy(() => import('./pages/admin/AdminStats'));
|
||||
|
||||
function AppInner() {
|
||||
const navigate = useNavigate();
|
||||
@@ -71,15 +74,19 @@ function AppInner() {
|
||||
// stats), not a page the user actually navigated to, so they'd otherwise
|
||||
// inflate the count with one extra row per redirect hop. Skip /admin itself
|
||||
// too -- that's the dashboard viewing its own traffic, not player usage.
|
||||
// Skip /, /lobby, /game -- high-frequency in-game navigation with no
|
||||
// analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS).
|
||||
useEffect(() => {
|
||||
if (navigationType === 'REPLACE') return;
|
||||
if (location.pathname.startsWith('/admin')) return;
|
||||
fetch('/api/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: location.pathname, referrer: document.referrer }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
if (
|
||||
location.pathname === '/' ||
|
||||
location.pathname.startsWith('/lobby') ||
|
||||
location.pathname.startsWith('/game')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackEvent(location.pathname, document.referrer);
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
return (
|
||||
@@ -89,6 +96,7 @@ function AppInner() {
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>}>
|
||||
<Routes>
|
||||
<Route path="/auth" element={<Auth />} />
|
||||
<Route path="/" element={<GameList />} />
|
||||
@@ -101,6 +109,7 @@ function AppInner() {
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { emit } from '../lib/socket';
|
||||
import { forbiddenGuess } from '../lib/gameRules';
|
||||
|
||||
interface Props {
|
||||
cardsInRound: number;
|
||||
@@ -9,11 +10,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
|
||||
const guessCount = Object.keys(guesses).length;
|
||||
const isMyTurn = activePlayer === myOrder;
|
||||
const isLastToGuess = guessCount === 3;
|
||||
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
|
||||
const forbidden = isLastToGuess ? cardsInRound - alreadySum : -1;
|
||||
const forbidden = forbiddenGuess(cardsInRound, guesses);
|
||||
|
||||
if (!isMyTurn) {
|
||||
return (
|
||||
|
||||
@@ -12,3 +12,12 @@ export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<str
|
||||
|
||||
return new Set(keys);
|
||||
}
|
||||
|
||||
/** The bid the last guesser may not make: the four bids must not sum to the
|
||||
* number of tricks in the round (mirrors Round.add_player_guess in the engine).
|
||||
* Returns null while earlier players are still guessing. */
|
||||
export function forbiddenGuess(cardsInRound: number, guesses: Record<string, number>): number | null {
|
||||
if (Object.keys(guesses).length !== 3) return null;
|
||||
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
|
||||
return cardsInRound - alreadySum;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,9 @@ export function setupSocketListeners() {
|
||||
|
||||
// Surface connection failures instead of silently buffering emits.
|
||||
socket.on('connect_error', (err: Error) => {
|
||||
// `active` = klient sa automaticky pokusi znova (typicky prebudeny mobil,
|
||||
// kym sa siet zobudi) -- prechodne, netreba strasit toastom.
|
||||
if (socket.active) return;
|
||||
useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/** Fire-and-forget event/pageview beacon for self-hosted analytics; must
|
||||
* never affect the app (network errors are swallowed). player_id is only
|
||||
* ever attached server-side to the "login" event -- ordinary beacons don't
|
||||
* carry it. */
|
||||
export function trackEvent(path: string, referrer = '') {
|
||||
fetch('/api/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, referrer }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const QUERY = '(min-width: 1024px)';
|
||||
|
||||
/** True on viewports >= 1024px — drives the desktop GameTable layout
|
||||
* (score sidebar, larger oval, bigger cards). */
|
||||
export function useIsDesktop(): boolean {
|
||||
const query = '(min-width: 1024px)';
|
||||
const [isDesktop, setIsDesktop] = useState(
|
||||
() => typeof window !== 'undefined' && window.matchMedia(query).matches,
|
||||
() => typeof window !== 'undefined' && window.matchMedia(QUERY).matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(query);
|
||||
const mql = window.matchMedia(QUERY);
|
||||
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
setIsDesktop(mql.matches);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { QRCodeSVG } from 'qrcode.react';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { trackEvent } from '../lib/track';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
|
||||
type Mode = 'login' | 'register';
|
||||
@@ -45,6 +46,11 @@ export default function Auth() {
|
||||
setRegistration(null);
|
||||
};
|
||||
|
||||
const handleShowRules = () => {
|
||||
setShowRules(true);
|
||||
trackEvent('rules_view');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
|
||||
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
|
||||
@@ -162,7 +168,7 @@ export default function Auth() {
|
||||
<div className="mt-10 flex items-center gap-3">
|
||||
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
onClick={handleShowRules}
|
||||
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
|
||||
>
|
||||
Pravidlá hry
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit, socket, setAuthToken } from '../lib/socket';
|
||||
import { trackEvent } from '../lib/track';
|
||||
import NameModal from '../components/NameModal';
|
||||
import RulesModal from '../components/RulesModal';
|
||||
|
||||
@@ -12,6 +13,11 @@ export default function GameList() {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showRules, setShowRules] = useState(false);
|
||||
|
||||
const handleShowRules = () => {
|
||||
setShowRules(true);
|
||||
trackEvent('rules_view');
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('bridzik_token');
|
||||
localStorage.removeItem('bridzik_player');
|
||||
@@ -85,7 +91,7 @@ export default function GameList() {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowRules(true)}
|
||||
onClick={handleShowRules}
|
||||
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
|
||||
>
|
||||
Pravidlá hry
|
||||
|
||||
@@ -33,6 +33,11 @@ export default function GameTable() {
|
||||
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const previousStash = gameStatus?.status.previous_stash ?? null;
|
||||
// Every game_status payload recreates the stash object, so identify the trick
|
||||
// by content — the timer must restart only when a *different* trick completes.
|
||||
const previousStashKey = previousStash
|
||||
? `${previousStash.first_player}:${JSON.stringify(previousStash.cards)}`
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousStash) return;
|
||||
@@ -43,7 +48,7 @@ export default function GameTable() {
|
||||
if (lingerTimer.current) clearTimeout(lingerTimer.current);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [gameStatus?.status.previous_stash?.first_player, JSON.stringify(gameStatus?.status.previous_stash?.cards)]);
|
||||
}, [previousStashKey]);
|
||||
|
||||
if (!gameStatus || !myPlayer) {
|
||||
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa…</p>;
|
||||
@@ -84,13 +89,12 @@ export default function GameTable() {
|
||||
const topP = seat(2);
|
||||
const rightP = seat(3);
|
||||
|
||||
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0);
|
||||
const guessOf = (o?: number): number | null => {
|
||||
if (o === undefined) return null;
|
||||
const g = active_round_guesses?.[String(o)];
|
||||
return g === undefined ? null : g;
|
||||
};
|
||||
const activeOf = (o?: number) => o !== undefined && active_player === o;
|
||||
// Live round state of one seat, shaped as PlayerCircle props.
|
||||
const seatProps = (o?: number) => ({
|
||||
won: o === undefined ? 0 : active_round_stashes?.[o] ?? 0,
|
||||
guess: (o === undefined ? null : active_round_guesses?.[String(o)] ?? null) as number | null,
|
||||
active: o !== undefined && active_player === o,
|
||||
});
|
||||
|
||||
// Exact cards still in a player's hand: started with cards_in_round, lost one
|
||||
// per completed trick, minus one more if they've already played this trick.
|
||||
@@ -114,7 +118,7 @@ export default function GameTable() {
|
||||
const canEnd = myOrder === 0 || !hostConnected;
|
||||
|
||||
// ── shared pieces ────────────────────────────────────────────────
|
||||
const bannerText = activeOf(myOrder)
|
||||
const bannerText = active_player === myOrder
|
||||
? isPlayPhase
|
||||
? 'Zahraj kartu'
|
||||
: 'Zadaj tip'
|
||||
@@ -171,39 +175,21 @@ export default function GameTable() {
|
||||
|
||||
const topSeat = (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle
|
||||
name={topP?.name ?? '—'}
|
||||
won={wonOf(topP?.order)}
|
||||
guess={guessOf(topP?.order)}
|
||||
active={activeOf(topP?.order)}
|
||||
size={desktop ? 64 : 52}
|
||||
/>
|
||||
<PlayerCircle name={topP?.name ?? '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
|
||||
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const sideSeat = (p?: PlayerInfo) => (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<PlayerCircle
|
||||
name={p?.name ?? '—'}
|
||||
won={wonOf(p?.order)}
|
||||
guess={guessOf(p?.order)}
|
||||
active={activeOf(p?.order)}
|
||||
size={desktop ? 60 : 48}
|
||||
/>
|
||||
<PlayerCircle name={p?.name ?? '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
|
||||
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
|
||||
</div>
|
||||
);
|
||||
|
||||
const meSeat = (
|
||||
<div className="flex justify-center">
|
||||
<PlayerCircle
|
||||
name={myPlayer.name}
|
||||
won={wonOf(myOrder)}
|
||||
guess={guessOf(myOrder)}
|
||||
active={activeOf(myOrder)}
|
||||
size={desktop ? 70 : 58}
|
||||
/>
|
||||
<PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () =>
|
||||
const blocks: number[][] = [];
|
||||
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
|
||||
return blocks.map((block, bi) => {
|
||||
const [sA, sB] = [block[0], block[1]];
|
||||
const [sA, sB] = block;
|
||||
const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
|
||||
return (
|
||||
<Fragment key={bi}>
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useGameStore } from '../store/gameStore';
|
||||
import { emit } from '../lib/socket';
|
||||
import { leaveGame } from '../lib/leaveGame';
|
||||
|
||||
export default function Lobby() {
|
||||
const { gid } = useParams<{ gid: string }>();
|
||||
const navigate = useNavigate();
|
||||
const myPlayer = useGameStore((s) => s.myPlayer);
|
||||
const games = useGameStore((s) => s.games);
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
const copiedTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const game = games.find((g) => g.gid === gid);
|
||||
const players = game?.players ?? [];
|
||||
const isHost = myPlayer?.order === 0;
|
||||
const canStart = players.length === 4 && isHost;
|
||||
|
||||
const handleLeave = () => {
|
||||
emit.leaveGame();
|
||||
useGameStore.getState().reset();
|
||||
localStorage.removeItem('bridzik_player');
|
||||
navigate('/', { replace: true });
|
||||
};
|
||||
const handleLeave = () => leaveGame(navigate);
|
||||
|
||||
const handleCopyCode = () => {
|
||||
if (gid) navigator.clipboard.writeText(gid);
|
||||
if (!gid) return;
|
||||
navigator.clipboard.writeText(`${window.location.origin}/lobby/${gid}`);
|
||||
setShowCopied(true);
|
||||
clearTimeout(copiedTimeout.current);
|
||||
copiedTimeout.current = setTimeout(() => setShowCopied(false), 1800);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(copiedTimeout.current), []);
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
|
||||
<div
|
||||
className={`fixed top-4 left-1/2 -translate-x-1/2 px-4 py-2 rounded-lg bg-header border border-gold/30 text-gold text-sm shadow-lg transition-opacity duration-300 z-50 ${
|
||||
showCopied ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
Odkaz na hru skopírovaný
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
|
||||
<button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
|
||||
@@ -42,7 +55,7 @@ export default function Lobby() {
|
||||
onClick={handleCopyCode}
|
||||
className="ml-3 px-3 py-1 rounded-lg text-sm border border-gold/30 text-gold hover:bg-gold hover:text-table transition-colors"
|
||||
>
|
||||
Kopírovať
|
||||
Kopírovať URL
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ interface DailyStats {
|
||||
peak_hours: Record<string, number>;
|
||||
rounds_per_day: Record<string, number>;
|
||||
pageviews_per_day: Record<string, number>;
|
||||
visitors_per_day: Record<string, number>;
|
||||
pageviews_per_day_by_device: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
|
||||
pageviews_per_day_by_os: Record<string, Record<string, number>>;
|
||||
@@ -100,15 +101,37 @@ function BreakdownTable({ title, data }: { title: string; data: Record<string, n
|
||||
);
|
||||
}
|
||||
|
||||
type Scope = 'all' | 'logged_in';
|
||||
|
||||
function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (s: Scope) => void }) {
|
||||
return (
|
||||
<div className="flex rounded-lg overflow-hidden border border-gold/20 self-start">
|
||||
{(['all', 'logged_in'] as Scope[]).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange(s)}
|
||||
className={`px-3 py-1.5 text-xs font-serif tracking-wide transition-colors ${
|
||||
scope === s ? 'bg-gold text-table' : 'bg-header text-green-dim hover:text-gold'
|
||||
}`}
|
||||
>
|
||||
{s === 'all' ? 'Všetci' : 'Prihlásení'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminStats() {
|
||||
const token = useAdminToken();
|
||||
const desktop = useIsDesktop();
|
||||
const [data, setData] = useState<DailyStats | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [scope, setScope] = useState<Scope>('all');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetch('/api/admin/stats', { headers: { Authorization: `Bearer ${token}` } })
|
||||
const qs = scope === 'logged_in' ? '?logged_in=1' : '';
|
||||
fetch(`/api/admin/stats${qs}`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => {
|
||||
if (res.status === 403) throw new Error('Neplatný token.');
|
||||
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
|
||||
@@ -123,7 +146,7 @@ export default function AdminStats() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token]);
|
||||
}, [token, scope]);
|
||||
|
||||
if (error) return <p className="text-red-400 text-sm">{error}</p>;
|
||||
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
|
||||
@@ -149,8 +172,15 @@ export default function AdminStats() {
|
||||
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
|
||||
</div>
|
||||
|
||||
{/* Prepinac sa tyka len traffic-analytickych widgetov nizsie (PageView) --
|
||||
herne metriky vyssie su uz vzdy o prihlasenych uctoch. */}
|
||||
<ScopeToggle scope={scope} onChange={setScope} />
|
||||
|
||||
<PageviewsChart
|
||||
total={data.pageviews_per_day}
|
||||
totalLabel={scope === 'logged_in' ? 'Prihlásení' : 'Zobrazenia'}
|
||||
visitors={data.visitors_per_day}
|
||||
visitorsLabel={scope === 'logged_in' ? 'Unikátní' : 'Návštevníci'}
|
||||
byDevice={data.pageviews_per_day_by_device}
|
||||
byBrowser={data.pageviews_per_day_by_browser}
|
||||
byOs={data.pageviews_per_day_by_os}
|
||||
|
||||
@@ -25,18 +25,32 @@ const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064
|
||||
|
||||
interface Props {
|
||||
total: Record<string, number>;
|
||||
totalLabel?: string;
|
||||
visitors: Record<string, number>;
|
||||
visitorsLabel?: string;
|
||||
byDevice: Record<string, Record<string, number>>;
|
||||
byBrowser: Record<string, Record<string, number>>;
|
||||
byOs: Record<string, Record<string, number>>;
|
||||
}
|
||||
|
||||
export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) {
|
||||
export default function PageviewsChart({
|
||||
total,
|
||||
totalLabel = 'Zobrazenia',
|
||||
visitors,
|
||||
visitorsLabel = 'Návštevníci',
|
||||
byDevice,
|
||||
byBrowser,
|
||||
byOs,
|
||||
}: Props) {
|
||||
const [dimension, setDimension] = useState<Dimension>('total');
|
||||
|
||||
const { rows, categories } = useMemo(() => {
|
||||
if (dimension === 'total') {
|
||||
const days = Object.keys(total).sort();
|
||||
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] };
|
||||
const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort();
|
||||
return {
|
||||
rows: days.map((day) => ({ day, n: total[day] ?? 0, visitors: visitors[day] ?? 0 })),
|
||||
categories: ['n', 'visitors'],
|
||||
};
|
||||
}
|
||||
const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
|
||||
const days = Object.keys(byDay).sort();
|
||||
@@ -47,7 +61,9 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
|
||||
return row;
|
||||
});
|
||||
return { rows, categories };
|
||||
}, [dimension, total, byDevice, byBrowser, byOs]);
|
||||
}, [dimension, total, visitors, byDevice, byBrowser, byOs]);
|
||||
|
||||
const lineName = (c: string) => (c === 'n' ? totalLabel : c === 'visitors' ? visitorsLabel : c);
|
||||
|
||||
return (
|
||||
<div className="bg-header border border-[#142018] rounded-xl p-4">
|
||||
@@ -73,13 +89,13 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
|
||||
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
|
||||
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
|
||||
<Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />
|
||||
{categories.map((c, i) => (
|
||||
<Line
|
||||
key={c}
|
||||
type="monotone"
|
||||
dataKey={c}
|
||||
name={dimension === 'total' ? 'Návštevy' : c}
|
||||
name={lineName(c)}
|
||||
stroke={PALETTE[i % PALETTE.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
|
||||
+183
-4
@@ -64,7 +64,7 @@ class StatsCase(unittest.TestCase):
|
||||
|
||||
def test_record_pageview_parses_user_agent(self):
|
||||
run(stats.record_pageview(
|
||||
path="/", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
|
||||
path="/history", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
|
||||
))
|
||||
|
||||
data = run(stats.get_daily_stats())
|
||||
@@ -73,7 +73,7 @@ class StatsCase(unittest.TestCase):
|
||||
self.assertIn("Windows", data["operating_systems"])
|
||||
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
|
||||
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1)
|
||||
self.assertGreaterEqual(data["top_paths"].get("/", 0), 1)
|
||||
self.assertGreaterEqual(data["top_paths"].get("/history", 0), 1)
|
||||
|
||||
# Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
|
||||
# ale IP sa uz do page_views ulozi.
|
||||
@@ -102,14 +102,193 @@ class StatsCase(unittest.TestCase):
|
||||
self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
|
||||
self.assertEqual(stats._country_for_ip(""), "")
|
||||
|
||||
def test_dynamic_path_segments_normalized(self):
|
||||
self.assertEqual(stats._normalize_path("/lobby/abc-123"), "/lobby")
|
||||
self.assertEqual(stats._normalize_path("/game/abc-123"), "/game")
|
||||
self.assertEqual(stats._normalize_path("/lobby"), "/lobby")
|
||||
self.assertEqual(stats._normalize_path("/history"), "/history")
|
||||
# /gameXYZ nie je /game/<id> -- nesmie sa orezat
|
||||
self.assertEqual(stats._normalize_path("/gamex"), "/gamex")
|
||||
|
||||
def test_skipped_paths_not_recorded(self):
|
||||
# "/", "/lobby" a "/game" (aj s dynamickym ID) sa vobec nezapisuju --
|
||||
# vysoka frekvencia bez analytickej hodnoty (api/stats.py _SKIPPED_PATHS).
|
||||
async def _count():
|
||||
from sqlalchemy import func, select as sa_select
|
||||
|
||||
from db.db import async_session
|
||||
from db.models import PageView
|
||||
|
||||
async with async_session() as session:
|
||||
return (
|
||||
await session.execute(sa_select(func.count()).select_from(PageView))
|
||||
).scalar()
|
||||
|
||||
before = run(_count())
|
||||
for path in ("/", "/lobby", "/lobby/abc-123", "/game", "/game/xyz-789"):
|
||||
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA))
|
||||
after = run(_count())
|
||||
self.assertEqual(after, before)
|
||||
|
||||
def test_event_path_stores_player_id(self):
|
||||
# Konvencia: nazov bez "/" na zaciatku = pomenovany event (napr. "login"
|
||||
# zapisovany zo socket handlera po uspesnom prihlaseni), nie URL cesta.
|
||||
ids = self._make_players(n=1)
|
||||
run(stats.record_pageview(
|
||||
path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0],
|
||||
))
|
||||
|
||||
async def _last():
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
from db.db import async_session
|
||||
from db.models import PageView
|
||||
|
||||
async with async_session() as session:
|
||||
row = (
|
||||
await session.execute(
|
||||
sa_select(PageView).order_by(PageView.id.desc()).limit(1)
|
||||
)
|
||||
).scalar_one()
|
||||
return row.path, row.player_id
|
||||
|
||||
path, player_id = run(_last())
|
||||
self.assertEqual(path, "login")
|
||||
self.assertEqual(player_id, ids[0])
|
||||
|
||||
data = run(stats.get_daily_stats())
|
||||
self.assertGreaterEqual(data["top_paths"].get("login", 0), 1)
|
||||
|
||||
def test_rules_view_event_recorded_without_player_id(self):
|
||||
run(stats.record_pageview(path="rules_view", referrer="", user_agent=CHROME_UA))
|
||||
data = run(stats.get_daily_stats())
|
||||
self.assertGreaterEqual(data["top_paths"].get("rules_view", 0), 1)
|
||||
|
||||
def test_logged_in_only_scope_counts_login_events_only(self):
|
||||
# Bezne beacony uz neposielaju player_id vobec -- scope "logged_in"
|
||||
# preto filtruje priamo podla path == "login", nie podla pritomnosti
|
||||
# player_id. Obycajna navsteva (aj s player_id) sa do neho nepocita.
|
||||
ids = self._make_players(n=1)
|
||||
ip = "203.0.113.99"
|
||||
before_all = run(stats.get_daily_stats(logged_in_only=False))
|
||||
before_logged_in = run(stats.get_daily_stats(logged_in_only=True))
|
||||
all_before = sum(before_all["pageviews_per_day"].values())
|
||||
logged_in_before = sum(before_logged_in["pageviews_per_day"].values())
|
||||
|
||||
# Bezna navsteva -- ma pribudnut len vo "vsetci", aj keby mala player_id.
|
||||
run(stats.record_pageview(
|
||||
path="/history", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
|
||||
))
|
||||
# Login event -- ma pribudnut v oboch.
|
||||
run(stats.record_pageview(
|
||||
path="login", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
|
||||
))
|
||||
|
||||
after_all = run(stats.get_daily_stats(logged_in_only=False))
|
||||
after_logged_in = run(stats.get_daily_stats(logged_in_only=True))
|
||||
|
||||
self.assertEqual(sum(after_all["pageviews_per_day"].values()), all_before + 2)
|
||||
self.assertEqual(sum(after_logged_in["pageviews_per_day"].values()), logged_in_before + 1)
|
||||
|
||||
def test_logged_in_only_breakdown_tables_count_each_login_not_deduped(self):
|
||||
# browsers/os/device_types/top_referrers/countries pouzivaju v scope
|
||||
# "logged_in" priamy pocet login-eventov, nie unikatny navstevnicky
|
||||
# den -- 2x prihlasenie tym istym prehliadacom/IP v ten isty den sa
|
||||
# ma prejavit ako 2, rovnako ako v grafe pageviews_per_day.
|
||||
ids = self._make_players(n=1)
|
||||
before = run(stats.get_daily_stats(logged_in_only=True))
|
||||
before_browsers = sum(before["browsers"].values())
|
||||
|
||||
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
|
||||
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
|
||||
|
||||
data = run(stats.get_daily_stats(logged_in_only=True))
|
||||
self.assertEqual(sum(data["browsers"].values()), before_browsers + 2)
|
||||
|
||||
def test_logged_in_visitors_per_day_counts_distinct_players_not_ip(self):
|
||||
# V scope "logged_in" ma visitors_per_day znamenat unikatnych HRACOV
|
||||
# (player_id) za den, nie unikatne IP+UA -- 2x prihlasenie toho
|
||||
# isteho hraca (aj z inej IP/prehliadaca) sa ma pocitat len raz, na
|
||||
# rozdiel od pageviews_per_day, kde sa kazdy login pocita zvlast.
|
||||
ids = self._make_players(n=2)
|
||||
before = run(stats.get_daily_stats(logged_in_only=True))
|
||||
before_visitors = sum(before["visitors_per_day"].values())
|
||||
before_pageviews = sum(before["pageviews_per_day"].values())
|
||||
|
||||
# Ten isty hrac, 2x prihlasenie z roznych "zariadeni" (rozne IP/UA).
|
||||
run(stats.record_pageview(
|
||||
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.10", player_id=ids[0],
|
||||
))
|
||||
run(stats.record_pageview(
|
||||
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.11", player_id=ids[0],
|
||||
))
|
||||
# Iny hrac, 1x prihlasenie.
|
||||
run(stats.record_pageview(
|
||||
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.12", player_id=ids[1],
|
||||
))
|
||||
|
||||
data = run(stats.get_daily_stats(logged_in_only=True))
|
||||
self.assertEqual(sum(data["pageviews_per_day"].values()), before_pageviews + 3)
|
||||
self.assertEqual(sum(data["visitors_per_day"].values()), before_visitors + 2)
|
||||
|
||||
def test_visitors_counted_once_per_day_per_ip(self):
|
||||
# 3 kliky z tej istej IP v ten isty den -> pageviews +3, visitors iba 1.
|
||||
ip = "198.51.100.77"
|
||||
for path in ("/history", "/history", "/history"):
|
||||
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA, ip=ip))
|
||||
|
||||
data = run(stats.get_daily_stats())
|
||||
today = next(iter(data["visitors_per_day"]))
|
||||
# V testovej DB su vsetky zaznamy z dneska; unikatnych IP je menej nez klikov.
|
||||
self.assertLess(data["visitors_per_day"][today], data["pageviews_per_day"][today])
|
||||
|
||||
# Tabulky zlozenia publika pocitaju den+IP raz -- 3 kliky tej istej IP
|
||||
# nesmu zdvihnut "Chrome" o 3. Overime, ze pocet je mensi nez pocet klikov.
|
||||
self.assertLess(data["browsers"].get("Chrome", 0), sum(data["pageviews_per_day"].values()))
|
||||
|
||||
def test_same_ip_different_user_agent_counts_as_two_visitors(self):
|
||||
ip = "192.0.2.44"
|
||||
firefox_ua = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) "
|
||||
"Gecko/20100101 Firefox/126.0"
|
||||
)
|
||||
before = run(stats.get_daily_stats())
|
||||
today_before = sum(before["visitors_per_day"].values())
|
||||
|
||||
# Ta ista IP, dva rozne prehliadace -> 2 navstevnici (kazdy klikne 2x).
|
||||
for _ in range(2):
|
||||
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
|
||||
run(stats.record_pageview(path="/history", referrer="", user_agent=firefox_ua, ip=ip))
|
||||
|
||||
after = run(stats.get_daily_stats())
|
||||
self.assertEqual(sum(after["visitors_per_day"].values()), today_before + 2)
|
||||
|
||||
def test_pageviews_per_day_breakdown_by_dimension(self):
|
||||
run(stats.record_pageview(path="/", referrer="", user_agent=CHROME_UA))
|
||||
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
|
||||
data = run(stats.get_daily_stats())
|
||||
today = next(iter(data["pageviews_per_day_by_device"]))
|
||||
self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1)
|
||||
self.assertIn("Chrome", data["pageviews_per_day_by_browser"][today])
|
||||
self.assertIn("Windows", data["pageviews_per_day_by_os"][today])
|
||||
|
||||
def test_all_scope_chart_breakdown_matches_composition_tables(self):
|
||||
# Bug hlaseny uzivatelom: v scope "vsetci" graf (pageviews_per_day_by_*)
|
||||
# pocital kazdy klik zvlast, zatial co tabulky nizsie (browsers/os/
|
||||
# device_types) pocitali unikatny "navstevnicky den" -- cisla si tak
|
||||
# nesedeli. Oboje ma teraz rovnaku dedup logiku (jeden navstevnik +
|
||||
# kategoria + den = 1), takze sucty musia byt zhodne.
|
||||
ip = "203.0.113.50"
|
||||
for _ in range(3):
|
||||
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
|
||||
|
||||
data = run(stats.get_daily_stats(logged_in_only=False))
|
||||
chart_pc_total = sum(day.get("pc", 0) for day in data["pageviews_per_day_by_device"].values())
|
||||
chart_chrome_total = sum(
|
||||
day.get("Chrome", 0) for day in data["pageviews_per_day_by_browser"].values()
|
||||
)
|
||||
self.assertEqual(chart_pc_total, data["device_types"].get("pc", 0))
|
||||
self.assertEqual(chart_chrome_total, data["browsers"].get("Chrome", 0))
|
||||
|
||||
def test_daily_stats_reflect_games_and_players(self):
|
||||
before = run(stats.get_daily_stats())
|
||||
base_total_players = before["total_players"]
|
||||
@@ -126,7 +305,7 @@ class StatsCase(unittest.TestCase):
|
||||
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
|
||||
|
||||
def test_empty_referrer_excluded_from_top_referrers(self):
|
||||
run(stats.record_pageview(path="/lobby", referrer="", user_agent=CHROME_UA))
|
||||
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
|
||||
data = run(stats.get_daily_stats())
|
||||
self.assertNotIn("", data["top_referrers"])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user