Author SHA1 Message Date
tim 9ade92d7fe Postgres kontajner: nastavenie timezone na Europe/Bratislava 2026-07-04 19:48:40 +02:00
timandClaude Fable 5 5b9d6342ad Auth: nedokoncena registracia sa da dokoncit novym QR kodom
Ucet bez potvrdeneho kodu (auth_token is NULL a totp_last_step == 0)
uz neblokuje meno: opakovany register_account vyda novy secret (stary
QR prestane platit) a login vyhodi RegistrationIncomplete, na ktoru
server odpovie novym QR -- klient sa prepne na registracny tab.
Admin statistiky vykazuju nedokoncene registracie osobitne, Hraci
celkom pocita len potvrdene ucty. Novy event register v analytike.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:34:03 +02:00
timandClaude Fable 5 53274607b1 Donate stranka: PAY by square QR s vyberom sumy
Linka Na kavu v hlavicke lobby vedie na /donate. QR sa generuje
dynamicky (balicek bysquare) pre presety 1/2/5 EUR alebo vlastnu sumu;
IBAN sa da skopirovat do schranky. Navstevy loguje existujuci
pageview beacon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:54:49 +02:00
timandClaude Fable 5 aed0cd5d7f Analytika: /auth sa uz nesleduje -- 1:1 duplicita landing eventu
Na /auth sa da dostat len plnym loadom stranky (vsetky interne
redirecty nan su REPLACE a beacon ich skipuje) a kazdy plny load uz
posiela event landing. Kazdy riadok /auth by tak mal dvojicku z toho
isteho loadu. Skip na frontende aj v _SKIPPED_PATHS (pokryje aj stare
cache-ovane verzie frontendu). Stare /auth riadky v DB ostavaju.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:06:39 +02:00
timandClaude Fable 5 42f8dfdcf0 Analytika: landing event zachytava prvu navstevu aj s referrerom
Navsteva zacinajuca na / (preklik z FB/Messengeru, natukanie domeny)
doteraz nezanechala v page_views ziadny zaznam: / je v skip liste
beaconu a gate redirect na /auth je REPLACE navigacia, takze sa skipli
oba hopy a referrer sa stratil. trackLanding() v main.tsx posle raz za
kazdy plny load stranky pomenovany event landing s document.referrer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 15:39:11 +02:00
tim 652a5aa66e Merge branch 'fixy' 2026-07-04 14:05:19 +02:00
tim 7be89e70c8 pridanie pgAdmin 2026-07-04 13:45:41 +02:00
timandClaude Sonnet 5 28bb045274 Rozsirenie analytiky: login event, prepinac vsetci/prihlaseni, plny nazov krajiny
Pageview beacony uz neposielaju player_id z klienta (nedovereny vstup na
neautentifikovanom endpointe) -- prihlasenie sa eviduje server-side ako
event "login" (aj z registracie), s player_id skutocneho uctu. Admin
dashboard dostal prepinac scope vsetci/prihlaseni, ktory konzistentne
pocita grafy aj tabulky (kazdy login samostatne, navstevnicky den pre
anonymnu navstevnost). PageView.country teraz uklada cely anglicky nazov
krajiny namiesto ISO kodu (potrebna zmena schemy). Pridane sledovanie
klikov na "Pravidla hry" aj z GameList.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 12:43:37 +02:00
tim 7886b3a6b8 fix rusenie hier ked vsetkych odpoji 2026-07-03 19:16:41 +02:00
tim c4800fca0e Oprava hashu commitu v TASKS.md pre hotovu ulohu 2026-07-03 10:41:51 +02:00
tim 49ac0d3e78 Kopirovat celu URL linku na hru namiesto holeho gid v Lobby
TASKS.md: uloha presunuta z Nedokoncene do Hotovo.
2026-07-03 10:41:40 +02:00
tim 3615fa2bb1 frontendove optimalizacie 2026-07-03 01:11:26 +02:00
tim 7a5202195b docker-compose update external network 2026-07-01 21:04:04 +02:00
26 changed files with 940 additions and 182 deletions
+5
View File
@@ -12,6 +12,11 @@ CORS_ALLOWED_ORIGINS=https://bridzik.liptim.eu
# Shared secret for the self-hosted /api/admin/* stats endpoints. # Shared secret for the self-hosted /api/admin/* stats endpoints.
ADMIN_TOKEN=tajneheslo 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: # Fernet key encrypting Player.totp_secret at rest. Generate with:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" # python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Keep it stable across restarts/redeploys -- losing it locks every account # Keep it stable across restarts/redeploys -- losing it locks every account
+16
View File
@@ -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. -->
+60 -12
View File
@@ -1,3 +1,4 @@
import asyncio
import hmac import hmac
import json import json
import os import os
@@ -5,6 +6,7 @@ import time
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from json import JSONEncoder from json import JSONEncoder
from urllib.parse import parse_qs
import socketio import socketio
@@ -12,7 +14,7 @@ from bridzik import Bridzik, BridzikException, Card
from db.db import init_db from db.db import init_db
from api import auth as auth_module, history from api import auth as auth_module, history
from api import stats as stats_module from api import stats as stats_module
from api.auth import AuthError from api.auth import AuthError, RegistrationIncomplete
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
@@ -102,6 +104,9 @@ async def _handle_track(scope, receive, send):
data = {} data = {}
headers = dict(scope.get("headers") or []) headers = dict(scope.get("headers") or [])
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300] 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( await stats_module.record_pageview(
path=str(data.get("path", ""))[:200], path=str(data.get("path", ""))[:200],
referrer=str(data.get("referrer", ""))[:300], referrer=str(data.get("referrer", ""))[:300],
@@ -119,7 +124,9 @@ async def _handle_admin_stats(scope, send):
if not _admin_authorized(scope): if not _admin_authorized(scope):
_register_admin_failure(ip) _register_admin_failure(ip)
return await _send_text(send, 403, b"forbidden") 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") await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
@@ -273,19 +280,37 @@ async def send_error(sid: str, message: str):
await sio.emit("error", {"error": message}, to=sid) 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"): async def _mark_player_offline(game: "Game", player: "Player"):
"""Mark player disconnected. An unstarted game with nobody left is cleaned """Mark player disconnected. An unstarted game with nobody left gets a
up; a started game is kept in memory so it stays in the lobby and can be delayed cleanup (mobile sockets drop on screen lock, so an immediate
resumed (it's torn down only by end_game).""" 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 player.connected = False
if not any(p.connected for p in game.players) and not game.started: if not any(p.connected for p in game.players) and not game.started:
del games[game.gid] asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
else: await sio.emit(
await sio.emit( "player_connection",
"player_connection", {"order": player.order, "connected": False},
{"order": player.order, "connected": False}, room=game.gid,
room=game.gid, )
)
def _active_game(sid: str) -> "tuple[Game, dict] | None": def _active_game(sid: str) -> "tuple[Game, dict] | None":
@@ -363,6 +388,18 @@ async def register_account(sid, username):
await sio.emit("register_account", data, to=sid) 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") @sio.on("confirm_account")
async def confirm_account(sid, username, code): async def confirm_account(sid, username, code):
try: try:
@@ -370,6 +407,7 @@ async def confirm_account(sid, username, code):
except AuthError as exc: except AuthError as exc:
return await send_error(sid, str(exc)) return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} 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) await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
@@ -377,9 +415,19 @@ async def confirm_account(sid, username, code):
async def login(sid, username, code): async def login(sid, username, code):
try: try:
identity = await auth_module.login(username, code) identity = await auth_module.login(username, code)
except RegistrationIncomplete as exc:
# Nedokoncena registracia -> vydame novy QR kod, klient sa prepne
# na registracny tab a pouzivatel ju moze dokoncit.
try:
data = await auth_module.register_account(username)
except AuthError as exc2:
return await send_error(sid, str(exc2))
await sio.emit("register_account", data, to=sid)
return await send_error(sid, str(exc))
except AuthError as exc: except AuthError as exc:
return await send_error(sid, str(exc)) return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]} 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) await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
+35 -6
View File
@@ -30,6 +30,18 @@ class AuthError(Exception):
"""Chyba prihlasenia/registracie (slovenska sprava pre klienta).""" """Chyba prihlasenia/registracie (slovenska sprava pre klienta)."""
class RegistrationIncomplete(AuthError):
"""Ucet existuje, ale registracia nebola nikdy potvrdena kodom.
Handler v api/__init__.py na nu reaguje novym QR kodom namiesto chyby.
"""
def _is_unconfirmed(player: Player) -> bool:
"""Ucet, ktory nikdy neoveril TOTP kod (confirm_account nastavuje oboje)."""
return player.auth_token is None and player.totp_last_step == 0
def _new_token() -> str: def _new_token() -> str:
return secrets.token_urlsafe(48) return secrets.token_urlsafe(48)
@@ -74,7 +86,11 @@ def _verify_code(player: Player, code: str) -> None:
async def register_account(username: str) -> dict: async def register_account(username: str) -> dict:
"""Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR.""" """Zaregistruje meno a vygeneruje TOTP secret. Vrati otpauth URI pre QR.
Nedokoncenu registraciu (meno existuje, ale kod nebol nikdy potvrdeny)
prepise novym secretom -- povodny QR kod tym prestane platit.
"""
username = (username or "").strip() username = (username or "").strip()
if not username: if not username:
raise AuthError("Zadajte meno.") raise AuthError("Zadajte meno.")
@@ -83,9 +99,12 @@ async def register_account(username: str) -> dict:
existing = await session.scalar( existing = await session.scalar(
select(Player).where(Player.username == username) select(Player).where(Player.username == username)
) )
if existing is not None: if existing is not None and not _is_unconfirmed(existing):
raise AuthError("Toto meno je už obsadené.") raise AuthError("Toto meno je už obsadené.")
session.add(Player(username=username, totp_secret=crypto.encrypt(secret))) if existing is not None:
existing.totp_secret = crypto.encrypt(secret)
else:
session.add(Player(username=username, totp_secret=crypto.encrypt(secret)))
await session.commit() await session.commit()
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER) otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri} return {"username": username, "secret": secret, "otpauth_uri": otpauth_uri}
@@ -97,11 +116,17 @@ async def confirm_account(username: str, code: str) -> dict:
async def login(username: str, code: str) -> dict: async def login(username: str, code: str) -> dict:
"""Prihlasi existujuci ucet a vrati session token.""" """Prihlasi existujuci ucet a vrati session token.
return await _verify_and_issue_token(username, code)
Pre nedokoncenu registraciu vyhodi RegistrationIncomplete namiesto
overovania kodu -- pouzivatel bez naskenovaneho QR ziadny kod nema.
"""
return await _verify_and_issue_token(username, code, unconfirmed_ok=False)
async def _verify_and_issue_token(username: str, code: str) -> dict: async def _verify_and_issue_token(
username: str, code: str, *, unconfirmed_ok: bool = True
) -> dict:
username = (username or "").strip() username = (username or "").strip()
_check_lockout(username) _check_lockout(username)
async with async_session() as session: async with async_session() as session:
@@ -109,6 +134,10 @@ async def _verify_and_issue_token(username: str, code: str) -> dict:
if player is None: if player is None:
_register_failure(username) _register_failure(username)
raise AuthError("Účet neexistuje.") raise AuthError("Účet neexistuje.")
if not unconfirmed_ok and _is_unconfirmed(player):
raise RegistrationIncomplete(
"Registrácia nie je dokončená — naskenuj QR kód a potvrď prvým kódom."
)
try: try:
_verify_code(player, (code or "").strip()) _verify_code(player, (code or "").strip())
except AuthError: except AuthError:
+185 -69
View File
@@ -19,8 +19,9 @@ _geoip_load_attempted = False
def _country_for_ip(ip: str) -> str: def _country_for_ip(ip: str) -> str:
"""ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je """Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.).""" ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
adresa a pod.)."""
global _geoip_reader, _geoip_load_attempted global _geoip_reader, _geoip_load_attempted
if not ip: if not ip:
return "" return ""
@@ -33,12 +34,39 @@ def _country_for_ip(ip: str) -> str:
return "" return ""
_geoip_reader = geoip2.database.Reader(path) _geoip_reader = geoip2.database.Reader(path)
try: try:
return _geoip_reader.country(ip).country.iso_code or "" return _geoip_reader.country(ip).country.name or ""
except (geoip2.errors.AddressNotFoundError, ValueError): except (geoip2.errors.AddressNotFoundError, ValueError):
return "" 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. "/auth" je zas 1:1 duplicita eventu
# "landing": dostat sa nan da len plnym loadom stranky (interne redirecty nan
# su REPLACE a beacon ich skipuje), a kazdy plny load uz posiela "landing"
# (main.tsx). Ostava len /history a pomenovane eventy (napr. "landing",
# "rules_view", "login"), ktore sem nespadaju.
_SKIPPED_PATHS = frozenset({"/", "/auth", "/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) ua = parse_ua(user_agent)
device_type = ( device_type = (
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc" "bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
@@ -46,7 +74,8 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "
async with async_session() as session: async with async_session() as session:
session.add( session.add(
PageView( PageView(
path=path, path=normalized,
player_id=player_id,
referrer=referrer, referrer=referrer,
user_agent=user_agent, user_agent=user_agent,
browser=ua.browser.family[:40], browser=ua.browser.family[:40],
@@ -61,35 +90,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 _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), """Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf. napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne 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) vday = func.date(PageView.created_at)
recent_days = ( if logged_in_only:
select(vday.label("day")) recent_days_q = select(vday.label("day")).distinct().where(PageView.path == "login")
.distinct() rows_q = (
.order_by(vday.desc())
.limit(_DAYS_WINDOW)
.subquery()
)
rows = (
await session.execute(
select(vday.label("day"), column.label("cat"), func.count().label("n")) select(vday.label("day"), column.label("cat"), func.count().label("n"))
.where(vday.in_(select(recent_days.c.day))) .where(PageView.path == "login")
.group_by(vday, column)
.order_by(vday.desc())
) )
).all() recent_days = recent_days_q.order_by(vday.desc()).limit(_DAYS_WINDOW).subquery()
rows = (
await session.execute(
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]] = {} nested: dict[str, dict[str, int]] = {}
for r in rows: for r in rows:
nested.setdefault(str(r.day), {})[r.cat] = r.n nested.setdefault(str(r.day), {})[r.cat] = r.n
return nested 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: async with async_session() as session:
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable # func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
# across SQLite and Postgres and returns a plain string/date value without # across SQLite and Postgres and returns a plain string/date value without
@@ -128,7 +228,18 @@ async def get_daily_stats() -> dict:
) )
).scalar() ).scalar()
total_players = (await session.execute(select(func.count(Player.id)))).scalar() # Nedokoncena registracia = kod nebol nikdy potvrdeny (SQL obdoba
# api/auth._is_unconfirmed). "Hraci celkom" pocita len potvrdene ucty,
# nedokoncene sa vykazuju osobitne.
unconfirmed = Player.auth_token.is_(None) & (Player.totp_last_step == 0)
total_players, unconfirmed_players = (
await session.execute(
select(
func.count(Player.id).filter(~unconfirmed),
func.count(Player.id).filter(unconfirmed),
)
)
).one()
peak_hours = ( peak_hours = (
await session.execute( await session.execute(
@@ -151,23 +262,31 @@ async def get_daily_stats() -> dict:
).all() ).all()
vday = func.date(PageView.created_at) 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 = ( pageview_rows = (
await session.execute( await session.execute(
select(vday.label("day"), func.count().label("n")) pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
.group_by(vday)
.order_by(vday.desc())
.limit(_DAYS_WINDOW)
) )
).all() ).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( await session.execute(
select(PageView.referrer, func.count().label("n")) visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
.where(PageView.referrer != "")
.group_by(PageView.referrer)
.order_by(func.count().desc())
.limit(20)
) )
).all() ).all()
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
top_paths = ( top_paths = (
await session.execute( await session.execute(
select(PageView.path, func.count().label("n")) select(PageView.path, func.count().label("n"))
@@ -177,39 +296,34 @@ async def get_daily_stats() -> dict:
) )
).all() ).all()
browsers = ( if logged_in_only:
await session.execute( # Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
select(PageView.browser, func.count().label("n")) # pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
.group_by(PageView.browser) # v ten isty den ma tiez pridat 2, nie 1.
.order_by(func.count().desc()) top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
) browsers = await _login_event_counts(session, PageView.browser)
).all() os_rows = await _login_event_counts(session, PageView.os)
os_rows = ( device_rows = await _login_event_counts(session, PageView.device_type)
await session.execute( country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
select(PageView.os, func.count().label("n")) else:
.group_by(PageView.os) # Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
.order_by(func.count().desc()) # dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
) # neprevazi tabulky.
).all() top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
device_rows = ( browsers = await _daily_unique_by(session, PageView.browser)
await session.execute( os_rows = await _daily_unique_by(session, PageView.os)
select(PageView.device_type, func.count().label("n")) device_rows = await _daily_unique_by(session, PageView.device_type)
.group_by(PageView.device_type) country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
.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()
pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type) pageviews_per_day_by_device = await _pageviews_by_day_and(
pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser) session, PageView.device_type, logged_in_only=logged_in_only
pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os) )
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 { return {
"games_per_day": {str(r.day): r.n for r in game_rows}, "games_per_day": {str(r.day): r.n for r in game_rows},
@@ -217,16 +331,18 @@ async def get_daily_stats() -> dict:
"completion_rate": finished / total if total else None, "completion_rate": finished / total if total else None,
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None, "avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
"total_players": total_players, "total_players": total_players,
"unconfirmed_players": unconfirmed_players,
"peak_hours": {int(r.h): r.n for r in peak_hours}, "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}, "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}, "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_device": pageviews_per_day_by_device,
"pageviews_per_day_by_browser": pageviews_per_day_by_browser, "pageviews_per_day_by_browser": pageviews_per_day_by_browser,
"pageviews_per_day_by_os": pageviews_per_day_by_os, "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}, "top_paths": {r.path: r.n for r in top_paths},
"browsers": {r.browser: r.n for r in browsers}, "browsers": {r.cat: r.n for r in browsers},
"operating_systems": {r.os: r.n for r in os_rows}, "operating_systems": {r.cat: r.n for r in os_rows},
"device_types": {r.device_type: r.n for r in device_rows}, "device_types": {r.cat: r.n for r in device_rows},
"countries": {r.country: r.n for r in country_rows}, "countries": {r.cat: r.n for r in country_rows},
} }
+7 -1
View File
@@ -93,14 +93,20 @@ class PageView(Base):
__tablename__ = "page_views" __tablename__ = "page_views"
id: Mapped[int] = mapped_column(primary_key=True) 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)) 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="") referrer: Mapped[str] = mapped_column(String(300), default="")
user_agent: Mapped[str] = mapped_column(String(300), default="") user_agent: Mapped[str] = mapped_column(String(300), default="")
browser: Mapped[str] = mapped_column(String(40), default="") browser: Mapped[str] = mapped_column(String(40), default="")
os: 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 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 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( created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now() DateTime, server_default=func.now()
) )
+27
View File
@@ -12,9 +12,26 @@ services:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
TZ: Europe/Bratislava
PGTZ: Europe/Bratislava
volumes: volumes:
- pgdata:/var/lib/postgresql - pgdata:/var/lib/postgresql
restart: always 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: backend:
build: . build: .
@@ -29,14 +46,24 @@ services:
restart: always restart: always
depends_on: depends_on:
- db - db
networks:
caddy_network:
frontend: frontend:
container_name: bridzik
build: ./frontend build: ./frontend
environment: environment:
BACKEND_UPSTREAM: backend:5000 BACKEND_UPSTREAM: backend:5000
restart: always restart: always
depends_on: depends_on:
- backend - backend
networks:
caddy_network:
volumes: volumes:
pgdata: pgdata:
pgadmin_data:
networks:
caddy_network:
external: true
+35
View File
@@ -8,6 +8,7 @@
"name": "bridzik-frontend", "name": "bridzik-frontend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"bysquare": "^4.0.0",
"qrcode.react": "^4.1.0", "qrcode.react": "^4.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
@@ -3152,6 +3153,22 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bysquare": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/bysquare/-/bysquare-4.0.0.tgz",
"integrity": "sha512-V1gGJFn39TY9XnvQDbzMTgGau3wCatsH5+XNSC2wXGgYijIVUD0gkZGalNIub28R+XhM/zUvge+8ccMPJ2+bZw==",
"license": "Apache-2.0",
"dependencies": {
"lzma1": "0.2.0",
"validator": "^13.15.26"
},
"bin": {
"bysquare": "lib/cli.js"
},
"funding": {
"url": "https://github.com/sponsors/xseman"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.9", "version": "1.0.9",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
@@ -5096,6 +5113,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lzma1": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/lzma1/-/lzma1-0.2.0.tgz",
"integrity": "sha512-JkPtHHKANh2je4Gp2kTjtobUXf7+zzfGHYm9PPRnx+Mr4KhAqoyb8dwtCExGaxkJl5upwWStdkIJWpn28XWiHw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/xseman"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -6952,6 +6978,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/validator": {
"version": "13.15.35",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/victory-vendor": { "node_modules/victory-vendor": {
"version": "36.9.2", "version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
+1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"bysquare": "^4.0.0",
"qrcode.react": "^4.1.0", "qrcode.react": "^4.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
+38 -21
View File
@@ -1,15 +1,19 @@
import { useEffect } from 'react'; import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
import { useGameStore } from './store/gameStore'; import { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket'; import { socket, emit } from './lib/socket';
import { trackEvent } from './lib/track';
import type { MyPlayer } from './types'; import type { MyPlayer } from './types';
import GameList from './pages/GameList'; import GameList from './pages/GameList';
import Lobby from './pages/Lobby'; import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable'; import GameTable from './pages/GameTable';
import Auth from './pages/Auth'; import Auth from './pages/Auth';
import History from './pages/History'; import History from './pages/History';
import AdminLayout from './pages/admin/AdminLayout'; import Donate from './pages/Donate';
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() { function AppInner() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -71,15 +75,25 @@ function AppInner() {
// stats), not a page the user actually navigated to, so they'd otherwise // 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 // 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. // 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).
// The visitor's first touch (incl. landings on "/", which both skips would
// otherwise swallow) is captured by trackLanding() in main.tsx instead.
// Skip /auth too: it's only ever reached by a full page load (every in-app
// redirect to it is REPLACE), so each /auth row would be a 1:1 duplicate of
// the landing event fired by that same load.
useEffect(() => { useEffect(() => {
if (navigationType === 'REPLACE') return; if (navigationType === 'REPLACE') return;
if (location.pathname.startsWith('/admin')) return; if (location.pathname.startsWith('/admin')) return;
fetch('/api/track', { if (
method: 'POST', location.pathname === '/' ||
headers: { 'Content-Type': 'application/json' }, location.pathname === '/auth' ||
body: JSON.stringify({ path: location.pathname, referrer: document.referrer }), location.pathname.startsWith('/lobby') ||
keepalive: true, location.pathname.startsWith('/game')
}).catch(() => {}); ) {
return;
}
trackEvent(location.pathname, document.referrer);
}, [location.pathname, navigationType]); }, [location.pathname, navigationType]);
return ( return (
@@ -89,18 +103,21 @@ function AppInner() {
{error} {error}
</div> </div>
)} )}
<Routes> <Suspense fallback={<p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa</p>}>
<Route path="/auth" element={<Auth />} /> <Routes>
<Route path="/" element={<GameList />} /> <Route path="/auth" element={<Auth />} />
<Route path="/history" element={<History />} /> <Route path="/" element={<GameList />} />
<Route path="/lobby/:gid" element={<Lobby />} /> <Route path="/history" element={<History />} />
<Route path="/game/:gid" element={<GameTable />} /> <Route path="/donate" element={<Donate />} />
<Route path="/admin" element={<AdminLayout />}> <Route path="/lobby/:gid" element={<Lobby />} />
<Route index element={<Navigate to="stats" replace />} /> <Route path="/game/:gid" element={<GameTable />} />
<Route path="stats" element={<AdminStats />} /> <Route path="/admin" element={<AdminLayout />}>
</Route> <Route index element={<Navigate to="stats" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="stats" element={<AdminStats />} />
</Routes> </Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</> </>
); );
} }
+2 -4
View File
@@ -1,4 +1,5 @@
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { forbiddenGuess } from '../lib/gameRules';
interface Props { interface Props {
cardsInRound: number; cardsInRound: number;
@@ -9,11 +10,8 @@ interface Props {
} }
export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) { export default function GuessControls({ cardsInRound, guesses, myOrder, activePlayer, activePlayerName }: Props) {
const guessCount = Object.keys(guesses).length;
const isMyTurn = activePlayer === myOrder; const isMyTurn = activePlayer === myOrder;
const isLastToGuess = guessCount === 3; const forbidden = forbiddenGuess(cardsInRound, guesses);
const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
const forbidden = isLastToGuess ? cardsInRound - alreadySum : -1;
if (!isMyTurn) { if (!isMyTurn) {
return ( return (
+9
View File
@@ -12,3 +12,12 @@ export function computePlayable(hand: Hand, ledColor: CardColor | null): Set<str
return new Set(keys); 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;
}
+3
View File
@@ -132,6 +132,9 @@ export function setupSocketListeners() {
// Surface connection failures instead of silently buffering emits. // Surface connection failures instead of silently buffering emits.
socket.on('connect_error', (err: Error) => { 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}`); useGameStore.getState().setError(`Spojenie so serverom zlyhalo: ${err.message}`);
}); });
} }
+23
View File
@@ -0,0 +1,23 @@
/** 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(() => {});
}
/** One "landing" event per full page load — the visitor's first touch,
* carrying document.referrer (e.g. facebook.com). Without it a visit that
* starts on "/" leaves no trace at all: "/" is in the beacon's skip list and
* the auth-gate redirect to /auth is a REPLACE navigation (see App.tsx), so
* both hops are skipped. Not sent on /admin — the dashboard doesn't track
* itself. */
export function trackLanding() {
if (window.location.pathname.startsWith('/admin')) return;
trackEvent('landing', document.referrer);
}
+4 -4
View File
@@ -1,18 +1,18 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
const QUERY = '(min-width: 1024px)';
/** True on viewports >= 1024px — drives the desktop GameTable layout /** True on viewports >= 1024px — drives the desktop GameTable layout
* (score sidebar, larger oval, bigger cards). */ * (score sidebar, larger oval, bigger cards). */
export function useIsDesktop(): boolean { export function useIsDesktop(): boolean {
const query = '(min-width: 1024px)';
const [isDesktop, setIsDesktop] = useState( const [isDesktop, setIsDesktop] = useState(
() => typeof window !== 'undefined' && window.matchMedia(query).matches, () => typeof window !== 'undefined' && window.matchMedia(QUERY).matches,
); );
useEffect(() => { useEffect(() => {
const mql = window.matchMedia(query); const mql = window.matchMedia(QUERY);
const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches); const handler = (e: MediaQueryListEvent) => setIsDesktop(e.matches);
mql.addEventListener('change', handler); mql.addEventListener('change', handler);
setIsDesktop(mql.matches);
return () => mql.removeEventListener('change', handler); return () => mql.removeEventListener('change', handler);
}, []); }, []);
+4
View File
@@ -3,11 +3,15 @@ import ReactDOM from 'react-dom/client';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
import { setupSocketListeners, socket, setAuthToken } from './lib/socket'; import { setupSocketListeners, socket, setAuthToken } from './lib/socket';
import { trackLanding } from './lib/track';
setupSocketListeners(); setupSocketListeners();
// Carry the stored session token into the handshake so the server auto-logs us in. // Carry the stored session token into the handshake so the server auto-logs us in.
setAuthToken(localStorage.getItem('bridzik_token')); setAuthToken(localStorage.getItem('bridzik_token'));
socket.connect(); socket.connect();
// Here (not in a component effect): runs exactly once per full page load,
// unaffected by StrictMode double-mounting.
trackLanding();
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
+18 -2
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { trackEvent } from '../lib/track';
import RulesModal from '../components/RulesModal'; import RulesModal from '../components/RulesModal';
type Mode = 'login' | 'register'; type Mode = 'login' | 'register';
@@ -19,6 +20,15 @@ export default function Auth() {
const remember = (name: string) => localStorage.setItem('bridzik_name', name.trim()); const remember = (name: string) => localStorage.setItem('bridzik_name', name.trim());
// Server can push a registration payload even from the login tab (unfinished
// registration → re-issued QR) — always land on the tab that shows it.
useEffect(() => {
if (registration) {
setMode('register');
setCode('');
}
}, [registration]);
const handleLogin = (e: React.FormEvent) => { const handleLogin = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!username.trim() || code.trim().length < 6) return; if (!username.trim() || code.trim().length < 6) return;
@@ -30,6 +40,7 @@ export default function Auth() {
e.preventDefault(); e.preventDefault();
if (!username.trim()) return; if (!username.trim()) return;
remember(username); remember(username);
trackEvent('register');
emit.registerAccount(username.trim()); emit.registerAccount(username.trim());
}; };
@@ -45,6 +56,11 @@ export default function Auth() {
setRegistration(null); setRegistration(null);
}; };
const handleShowRules = () => {
setShowRules(true);
trackEvent('rules_view');
};
return ( return (
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen"> <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> <h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
@@ -162,7 +178,7 @@ export default function Auth() {
<div className="mt-10 flex items-center gap-3"> <div className="mt-10 flex items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" /> <div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
<button <button
onClick={() => setShowRules(true)} onClick={handleShowRules}
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors" className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
> >
Pravidlá hry Pravidlá hry
+113
View File
@@ -0,0 +1,113 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { QRCodeSVG } from 'qrcode.react';
import { encode, PaymentOptions, CurrencyCode } from 'bysquare/pay';
const IBAN = 'SK2583300000002803542822';
const AMOUNTS = [1, 2, 5];
const PAYMENT_NOTE = 'Bridzik';
const BENEFICIARY = 'Liptak Timotej';
export default function Donate() {
const navigate = useNavigate();
const [amount, setAmount] = useState(2);
const [custom, setCustom] = useState('');
const [copied, setCopied] = useState(false);
// Vlastna suma ma prednost pred presetom; akceptuje ciarku aj bodku.
const customParsed = parseFloat(custom.replace(',', '.'));
const customValid = custom !== '' && !isNaN(customParsed) && customParsed > 0;
const effectiveAmount = customValid ? Math.round(customParsed * 100) / 100 : amount;
const qrValue = useMemo(
() =>
encode({
payments: [
{
type: PaymentOptions.PaymentOrder,
amount: effectiveAmount,
currencyCode: CurrencyCode.EUR,
paymentNote: PAYMENT_NOTE,
beneficiary: { name: BENEFICIARY },
bankAccounts: [{ iban: IBAN }],
},
],
}),
[effectiveAmount],
);
const copyIban = async () => {
await navigator.clipboard.writeText(IBAN);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen">
<div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">Podpor Bridžik </h1>
<button onClick={() => navigate('/')} className="text-sm text-green-dim hover:text-gold">
Späť
</button>
</div>
<div className="bg-header border border-[#142018] rounded-2xl p-6 text-sm text-green-score">
<p className="text-green-dim mb-4">
Bridžik je zadarmo. Ak sa ti hra páči, dobrovoľný príspevok pomôže s prevádzkou servera a rozvojom nových funkcionalít.
</p>
<div className="flex gap-2 mb-2">
{AMOUNTS.map((a) => (
<button
key={a}
onClick={() => {
setAmount(a);
setCustom('');
}}
className={`flex-1 py-1.5 rounded-lg font-serif font-semibold transition-colors ${
!customValid && amount === a
? 'bg-gold text-table'
: 'border border-gold/40 text-gold hover:bg-gold/10'
}`}
>
{a}
</button>
))}
</div>
<div
className={`flex items-center gap-2 rounded-lg border px-3 py-1.5 mb-4 transition-colors ${
customValid ? 'border-gold bg-gold/10' : 'border-gold/40'
}`}
>
<input
type="text"
inputMode="decimal"
placeholder="Vlastná suma"
value={custom}
onChange={(e) => setCustom(e.target.value)}
className="flex-1 bg-transparent outline-none text-gold placeholder-green-dim font-serif font-semibold"
/>
<span className="text-gold font-serif font-semibold"></span>
</div>
<div className="bg-white rounded-xl p-4 flex justify-center mb-2">
<QRCodeSVG value={qrValue} size={176} />
</div>
<p className="text-center text-xs text-green-dim mb-4">
Naskenuj QR kód bankovou aplikáciou (PAY by square)
</p>
<div className="flex items-center justify-between gap-2 border border-gold/20 rounded-lg px-3 py-2">
<span className="font-mono text-xs break-all">{IBAN}</span>
<button
onClick={copyIban}
className="shrink-0 text-gold hover:text-gold-bright text-xs font-semibold"
>
{copied ? 'Skopírované ✓' : 'Kopírovať'}
</button>
</div>
</div>
</div>
);
}
+10 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit, socket, setAuthToken } from '../lib/socket'; import { emit, socket, setAuthToken } from '../lib/socket';
import { trackEvent } from '../lib/track';
import NameModal from '../components/NameModal'; import NameModal from '../components/NameModal';
import RulesModal from '../components/RulesModal'; import RulesModal from '../components/RulesModal';
@@ -12,6 +13,11 @@ export default function GameList() {
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [showRules, setShowRules] = useState(false); const [showRules, setShowRules] = useState(false);
const handleShowRules = () => {
setShowRules(true);
trackEvent('rules_view');
};
const handleLogout = () => { const handleLogout = () => {
localStorage.removeItem('bridzik_token'); localStorage.removeItem('bridzik_token');
localStorage.removeItem('bridzik_player'); localStorage.removeItem('bridzik_player');
@@ -32,6 +38,9 @@ export default function GameList() {
<button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright"> <button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
História História
</button> </button>
<button onClick={() => navigate('/donate')} className="text-gold hover:text-gold-bright">
Na kávu
</button>
<button onClick={handleLogout} className="text-green-dim hover:text-gold"> <button onClick={handleLogout} className="text-green-dim hover:text-gold">
Odhlásiť Odhlásiť
</button> </button>
@@ -85,7 +94,7 @@ export default function GameList() {
</button> </button>
<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" className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
> >
Pravidlá hry Pravidlá hry
+16 -30
View File
@@ -33,6 +33,11 @@ export default function GameTable() {
const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const lingerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const previousStash = gameStatus?.status.previous_stash ?? 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(() => { useEffect(() => {
if (!previousStash) return; if (!previousStash) return;
@@ -43,7 +48,7 @@ export default function GameTable() {
if (lingerTimer.current) clearTimeout(lingerTimer.current); if (lingerTimer.current) clearTimeout(lingerTimer.current);
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // 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) { if (!gameStatus || !myPlayer) {
return <p className="text-center text-green-dim pt-20 font-serif italic">Načítava sa</p>; 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 topP = seat(2);
const rightP = seat(3); const rightP = seat(3);
const wonOf = (o?: number) => (o === undefined ? 0 : active_round_stashes?.[o] ?? 0); // Live round state of one seat, shaped as PlayerCircle props.
const guessOf = (o?: number): number | null => { const seatProps = (o?: number) => ({
if (o === undefined) return null; won: o === undefined ? 0 : active_round_stashes?.[o] ?? 0,
const g = active_round_guesses?.[String(o)]; guess: (o === undefined ? null : active_round_guesses?.[String(o)] ?? null) as number | null,
return g === undefined ? null : g; active: o !== undefined && active_player === o,
}; });
const activeOf = (o?: number) => o !== undefined && active_player === o;
// Exact cards still in a player's hand: started with cards_in_round, lost one // 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. // 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; const canEnd = myOrder === 0 || !hostConnected;
// ── shared pieces ──────────────────────────────────────────────── // ── shared pieces ────────────────────────────────────────────────
const bannerText = activeOf(myOrder) const bannerText = active_player === myOrder
? isPlayPhase ? isPlayPhase
? 'Zahraj kartu' ? 'Zahraj kartu'
: 'Zadaj tip' : 'Zadaj tip'
@@ -171,39 +175,21 @@ export default function GameTable() {
const topSeat = ( const topSeat = (
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<PlayerCircle <PlayerCircle name={topP?.name ?? '—'} {...seatProps(topP?.order)} size={desktop ? 64 : 52} />
name={topP?.name ?? '—'}
won={wonOf(topP?.order)}
guess={guessOf(topP?.order)}
active={activeOf(topP?.order)}
size={desktop ? 64 : 52}
/>
<FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} /> <FaceDownCards count={cardsInHandOf(topP?.order)} direction="row" desktop={desktop} />
</div> </div>
); );
const sideSeat = (p?: PlayerInfo) => ( const sideSeat = (p?: PlayerInfo) => (
<div className="flex flex-col items-center gap-1.5"> <div className="flex flex-col items-center gap-1.5">
<PlayerCircle <PlayerCircle name={p?.name ?? '—'} {...seatProps(p?.order)} size={desktop ? 60 : 48} />
name={p?.name ?? '—'}
won={wonOf(p?.order)}
guess={guessOf(p?.order)}
active={activeOf(p?.order)}
size={desktop ? 60 : 48}
/>
<FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} /> <FaceDownCards count={cardsInHandOf(p?.order)} direction="col" desktop={desktop} />
</div> </div>
); );
const meSeat = ( const meSeat = (
<div className="flex justify-center"> <div className="flex justify-center">
<PlayerCircle <PlayerCircle name={myPlayer.name} {...seatProps(myOrder)} size={desktop ? 70 : 58} />
name={myPlayer.name}
won={wonOf(myOrder)}
guess={guessOf(myOrder)}
active={activeOf(myOrder)}
size={desktop ? 70 : 58}
/>
</div> </div>
); );
+1 -1
View File
@@ -243,7 +243,7 @@ function GameDetailView({ detail, onBack }: { detail: GameDetail; onBack: () =>
const blocks: number[][] = []; const blocks: number[][] = [];
for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2)); for (let i = 0; i < seriesNums.length; i += 2) blocks.push(seriesNums.slice(i, i + 2));
return blocks.map((block, bi) => { 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); const roundNums = [...new Set([...roundsOf(sA), ...roundsOf(sB)])].sort((a, b) => a - b);
return ( return (
<Fragment key={bi}> <Fragment key={bi}>
+21 -8
View File
@@ -1,31 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useGameStore } from '../store/gameStore'; import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket'; import { emit } from '../lib/socket';
import { leaveGame } from '../lib/leaveGame';
export default function Lobby() { export default function Lobby() {
const { gid } = useParams<{ gid: string }>(); const { gid } = useParams<{ gid: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const myPlayer = useGameStore((s) => s.myPlayer); const myPlayer = useGameStore((s) => s.myPlayer);
const games = useGameStore((s) => s.games); 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 game = games.find((g) => g.gid === gid);
const players = game?.players ?? []; const players = game?.players ?? [];
const isHost = myPlayer?.order === 0; const isHost = myPlayer?.order === 0;
const canStart = players.length === 4 && isHost; const canStart = players.length === 4 && isHost;
const handleLeave = () => { const handleLeave = () => leaveGame(navigate);
emit.leaveGame();
useGameStore.getState().reset();
localStorage.removeItem('bridzik_player');
navigate('/', { replace: true });
};
const handleCopyCode = () => { 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 ( return (
<div className="max-w-md mx-auto p-4 pt-8 min-h-screen"> <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"> <div className="flex items-center justify-between mb-6">
<h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1> <h1 className="font-serif text-2xl text-gold">{game?.name ?? 'Hra'}</h1>
<button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold"> <button onClick={handleLeave} className="text-sm text-green-dim hover:text-gold">
@@ -42,7 +55,7 @@ export default function Lobby() {
onClick={handleCopyCode} 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" 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> </button>
</div> </div>
+34 -2
View File
@@ -20,9 +20,11 @@ interface DailyStats {
completion_rate: number | null; completion_rate: number | null;
avg_game_duration_minutes: number | null; avg_game_duration_minutes: number | null;
total_players: number; total_players: number;
unconfirmed_players: number;
peak_hours: Record<string, number>; peak_hours: Record<string, number>;
rounds_per_day: Record<string, number>; rounds_per_day: Record<string, number>;
pageviews_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_device: Record<string, Record<string, number>>;
pageviews_per_day_by_browser: 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>>; pageviews_per_day_by_os: Record<string, Record<string, number>>;
@@ -100,15 +102,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() { export default function AdminStats() {
const token = useAdminToken(); const token = useAdminToken();
const desktop = useIsDesktop(); const desktop = useIsDesktop();
const [data, setData] = useState<DailyStats | null>(null); const [data, setData] = useState<DailyStats | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<Scope>('all');
useEffect(() => { useEffect(() => {
let cancelled = false; 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) => { .then((res) => {
if (res.status === 403) throw new Error('Neplatný token.'); if (res.status === 403) throw new Error('Neplatný token.');
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.'); if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
@@ -123,7 +147,7 @@ export default function AdminStats() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [token]); }, [token, scope]);
if (error) return <p className="text-red-400 text-sm">{error}</p>; 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>; if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
@@ -132,6 +156,7 @@ export default function AdminStats() {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}> <div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
<SummaryCard label="Hráči celkom" value={String(data.total_players)} /> <SummaryCard label="Hráči celkom" value={String(data.total_players)} />
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
<SummaryCard <SummaryCard
label="Dokončené hry" label="Dokončené hry"
value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'} value={data.completion_rate != null ? `${Math.round(data.completion_rate * 100)}%` : '—'}
@@ -149,8 +174,15 @@ export default function AdminStats() {
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" /> <TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
</div> </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 <PageviewsChart
total={data.pageviews_per_day} 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} byDevice={data.pageviews_per_day_by_device}
byBrowser={data.pageviews_per_day_by_browser} byBrowser={data.pageviews_per_day_by_browser}
byOs={data.pageviews_per_day_by_os} byOs={data.pageviews_per_day_by_os}
+22 -6
View File
@@ -25,18 +25,32 @@ const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064
interface Props { interface Props {
total: Record<string, number>; total: Record<string, number>;
totalLabel?: string;
visitors: Record<string, number>;
visitorsLabel?: string;
byDevice: Record<string, Record<string, number>>; byDevice: Record<string, Record<string, number>>;
byBrowser: Record<string, Record<string, number>>; byBrowser: Record<string, Record<string, number>>;
byOs: 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 [dimension, setDimension] = useState<Dimension>('total');
const { rows, categories } = useMemo(() => { const { rows, categories } = useMemo(() => {
if (dimension === 'total') { if (dimension === 'total') {
const days = Object.keys(total).sort(); const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort();
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] }; 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 byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
const days = Object.keys(byDay).sort(); const days = Object.keys(byDay).sort();
@@ -47,7 +61,9 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
return row; return row;
}); });
return { rows, categories }; 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 ( return (
<div className="bg-header border border-[#142018] rounded-xl p-4"> <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 }} /> <XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} /> <YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} /> <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) => ( {categories.map((c, i) => (
<Line <Line
key={c} key={c}
type="monotone" type="monotone"
dataKey={c} dataKey={c}
name={dimension === 'total' ? 'Návštevy' : c} name={lineName(c)}
stroke={PALETTE[i % PALETTE.length]} stroke={PALETTE[i % PALETTE.length]}
strokeWidth={2} strokeWidth={2}
dot={false} dot={false}
+35 -10
View File
@@ -57,24 +57,37 @@ class HistoryCase(unittest.TestCase):
for _ in range(n): for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8] username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"]) ids.append(ident["player_id"])
return ids return ids
@staticmethod
def _next_step_code(secret):
"""Kod pre NASLEDUJUCI casovy krok -- confirm uz spotreboval aktualny."""
return pyotp.TOTP(secret).at((auth._current_step() + 1) * auth.TOTP_PERIOD)
def test_register_login_token(self): def test_register_login_token(self):
username = "alice_" + uuid.uuid4().hex[:6] username = "alice_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
self.assertIn("otpauth_uri", data) self.assertIn("otpauth_uri", data)
# Zle meno je obsadene # Nedokoncena registracia: opakovany register vyda NOVY secret
with self.assertRaises(auth.AuthError): data = run(auth.register_account(username))
run(auth.register_account(username)) self.assertIn("otpauth_uri", data)
# Login pred potvrdenim -> RegistrationIncomplete (novy QR namiesto chyby)
with self.assertRaises(auth.RegistrationIncomplete):
run(auth.login(username, "000000"))
code = pyotp.TOTP(data["secret"]).now() code = pyotp.TOTP(data["secret"]).now()
ident = run(auth.login(username, code)) ident = run(auth.confirm_account(username, code))
self.assertEqual(ident["username"], username) self.assertEqual(ident["username"], username)
self.assertTrue(ident["token"]) self.assertTrue(ident["token"])
# Po potvrdeni je uz meno obsadene
with self.assertRaises(auth.AuthError):
run(auth.register_account(username))
# Token sa da spatne rozlustit na identitu # Token sa da spatne rozlustit na identitu
resolved = run(auth.player_by_token(ident["token"])) resolved = run(auth.player_by_token(ident["token"]))
self.assertEqual(resolved["player_id"], ident["player_id"]) self.assertEqual(resolved["player_id"], ident["player_id"])
@@ -83,29 +96,41 @@ class HistoryCase(unittest.TestCase):
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000")) run(auth.login(username, "000000"))
def test_reissued_secret_invalidates_old_qr(self):
username = "fred_" + uuid.uuid4().hex[:6]
old = run(auth.register_account(username))
new = run(auth.register_account(username))
self.assertNotEqual(old["secret"], new["secret"])
# Kod zo stareho QR uz neplati, z noveho ano
with self.assertRaises(auth.AuthError):
run(auth.confirm_account(username, pyotp.TOTP(old["secret"]).now()))
ident = run(auth.confirm_account(username, pyotp.TOTP(new["secret"]).now()))
self.assertEqual(ident["username"], username)
def test_login_lockout_after_repeated_failures(self): def test_login_lockout_after_repeated_failures(self):
username = "bob_" + uuid.uuid4().hex[:6] username = "bob_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT): for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000")) run(auth.login(username, "000000"))
# Lockout odmietne aj spravny kod, kym neubehne okno # Lockout odmietne aj spravny kod, kym neubehne okno
code = pyotp.TOTP(data["secret"]).now()
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, code)) run(auth.login(username, self._next_step_code(data["secret"])))
def test_successful_login_clears_failed_attempts(self): def test_successful_login_clears_failed_attempts(self):
username = "carol_" + uuid.uuid4().hex[:6] username = "carol_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1): for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
with self.assertRaises(auth.AuthError): with self.assertRaises(auth.AuthError):
run(auth.login(username, "000000")) run(auth.login(username, "000000"))
code = pyotp.TOTP(data["secret"]).now() ident = run(auth.login(username, self._next_step_code(data["secret"])))
ident = run(auth.login(username, code))
self.assertEqual(ident["username"], username) self.assertEqual(ident["username"], username)
self.assertNotIn(username, auth._failed_attempts) self.assertNotIn(username, auth._failed_attempts)
@@ -241,7 +266,7 @@ class HistoryCase(unittest.TestCase):
def test_auth_token_stored_hashed_not_plaintext(self): def test_auth_token_stored_hashed_not_plaintext(self):
username = "erin_" + uuid.uuid4().hex[:6] username = "erin_" + uuid.uuid4().hex[:6]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
async def _raw_token(): async def _raw_token():
async with async_session() as session: async with async_session() as session:
+216 -5
View File
@@ -58,13 +58,13 @@ class StatsCase(unittest.TestCase):
for _ in range(n): for _ in range(n):
username = "u_" + uuid.uuid4().hex[:8] username = "u_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username)) data = run(auth.register_account(username))
ident = run(auth.login(username, pyotp.TOTP(data["secret"]).now())) ident = run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
ids.append(ident["player_id"]) ids.append(ident["player_id"])
return ids return ids
def test_record_pageview_parses_user_agent(self): def test_record_pageview_parses_user_agent(self):
run(stats.record_pageview( 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()) data = run(stats.get_daily_stats())
@@ -73,7 +73,7 @@ class StatsCase(unittest.TestCase):
self.assertIn("Windows", data["operating_systems"]) self.assertIn("Windows", data["operating_systems"])
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1) self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 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, # Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
# ale IP sa uz do page_views ulozi. # ale IP sa uz do page_views ulozi.
@@ -102,14 +102,208 @@ class StatsCase(unittest.TestCase):
self.assertEqual(stats._country_for_ip("203.0.113.5"), "") self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
self.assertEqual(stats._country_for_ip(""), "") 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; "/auth" zas preto, ze je
# to vzdy 1:1 duplicita eventu "landing" (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 ("/", "/auth", "/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_landing_event_preserves_referrer(self):
# "landing" event (main.tsx, jeden na kazdy plny load stranky) nesie
# referrer prveho dotyku -- jediny zaznam z navstevy, ktora zacina na
# "/" (preklik z FB a pod.), kedze "/" aj REPLACE redirecty sa skipuju.
run(stats.record_pageview(
path="landing",
referrer="https://facebook.com/",
user_agent=CHROME_UA,
ip="203.0.113.99",
))
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("landing", 0), 1)
self.assertGreaterEqual(data["top_referrers"].get("https://facebook.com/", 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): 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()) data = run(stats.get_daily_stats())
today = next(iter(data["pageviews_per_day_by_device"])) today = next(iter(data["pageviews_per_day_by_device"]))
self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1) 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("Chrome", data["pageviews_per_day_by_browser"][today])
self.assertIn("Windows", data["pageviews_per_day_by_os"][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): def test_daily_stats_reflect_games_and_players(self):
before = run(stats.get_daily_stats()) before = run(stats.get_daily_stats())
base_total_players = before["total_players"] base_total_players = before["total_players"]
@@ -125,8 +319,25 @@ class StatsCase(unittest.TestCase):
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1) self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4) self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
def test_unconfirmed_players_counted_separately(self):
before = run(stats.get_daily_stats())
# Registracia bez potvrdenia kodu -> nedokonceny ucet
username = "ghost_" + uuid.uuid4().hex[:8]
data = run(auth.register_account(username))
after = run(stats.get_daily_stats())
self.assertEqual(after["total_players"], before["total_players"])
self.assertEqual(after["unconfirmed_players"], before["unconfirmed_players"] + 1)
# Po potvrdeni sa presunie medzi potvrdenych hracov
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
confirmed = run(stats.get_daily_stats())
self.assertEqual(confirmed["total_players"], before["total_players"] + 1)
self.assertEqual(confirmed["unconfirmed_players"], before["unconfirmed_players"])
def test_empty_referrer_excluded_from_top_referrers(self): 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()) data = run(stats.get_daily_stats())
self.assertNotIn("", data["top_referrers"]) self.assertNotIn("", data["top_referrers"])