Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ade92d7fe | ||
|
|
5b9d6342ad | ||
|
|
53274607b1 | ||
|
|
aed0cd5d7f | ||
|
|
42f8dfdcf0 | ||
|
|
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. -->
|
||||
+55
-7
@@ -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
|
||||
|
||||
@@ -12,7 +14,7 @@ from bridzik import Bridzik, BridzikException, Card
|
||||
from db.db import init_db
|
||||
from api import auth as auth_module, history
|
||||
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:
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -377,9 +415,19 @@ async def confirm_account(sid, username, code):
|
||||
async def login(sid, username, code):
|
||||
try:
|
||||
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:
|
||||
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)
|
||||
|
||||
|
||||
|
||||
+34
-5
@@ -30,6 +30,18 @@ class AuthError(Exception):
|
||||
"""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:
|
||||
return secrets.token_urlsafe(48)
|
||||
|
||||
@@ -74,7 +86,11 @@ def _verify_code(player: Player, code: str) -> None:
|
||||
|
||||
|
||||
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()
|
||||
if not username:
|
||||
raise AuthError("Zadajte meno.")
|
||||
@@ -83,8 +99,11 @@ async def register_account(username: str) -> dict:
|
||||
existing = await session.scalar(
|
||||
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é.")
|
||||
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()
|
||||
otpauth_uri = pyotp.TOTP(secret).provisioning_uri(name=username, issuer_name=ISSUER)
|
||||
@@ -97,11 +116,17 @@ async def confirm_account(username: str, code: str) -> dict:
|
||||
|
||||
|
||||
async def login(username: str, code: str) -> dict:
|
||||
"""Prihlasi existujuci ucet a vrati session token."""
|
||||
return await _verify_and_issue_token(username, code)
|
||||
"""Prihlasi existujuci ucet a vrati session token.
|
||||
|
||||
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()
|
||||
_check_lockout(username)
|
||||
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:
|
||||
_register_failure(username)
|
||||
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:
|
||||
_verify_code(player, (code or "").strip())
|
||||
except AuthError:
|
||||
|
||||
+180
-64
@@ -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,39 @@ 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. "/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)
|
||||
device_type = (
|
||||
"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:
|
||||
session.add(
|
||||
PageView(
|
||||
path=path,
|
||||
path=normalized,
|
||||
player_id=player_id,
|
||||
referrer=referrer,
|
||||
user_agent=user_agent,
|
||||
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
|
||||
|
||||
# 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
|
||||
@@ -128,7 +228,18 @@ async def get_daily_stats() -> dict:
|
||||
)
|
||||
).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 = (
|
||||
await session.execute(
|
||||
@@ -151,23 +262,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 +296,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},
|
||||
@@ -217,16 +331,18 @@ async def get_daily_stats() -> dict:
|
||||
"completion_rate": finished / total if total else None,
|
||||
"avg_game_duration_minutes": (avg_duration / 60) if avg_duration else None,
|
||||
"total_players": total_players,
|
||||
"unconfirmed_players": unconfirmed_players,
|
||||
"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()
|
||||
)
|
||||
|
||||
@@ -12,9 +12,26 @@ services:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
TZ: Europe/Bratislava
|
||||
PGTZ: Europe/Bratislava
|
||||
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 +46,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
|
||||
Generated
+35
@@ -8,6 +8,7 @@
|
||||
"name": "bridzik-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bysquare": "^4.0.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -3152,6 +3153,22 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz",
|
||||
@@ -5096,6 +5113,15 @@
|
||||
"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": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
@@ -6952,6 +6978,15 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "36.9.2",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"bysquare": "^4.0.0",
|
||||
"qrcode.react": "^4.1.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
+26
-9
@@ -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 { 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';
|
||||
import Donate from './pages/Donate';
|
||||
|
||||
// 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 +75,25 @@ 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).
|
||||
// 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(() => {
|
||||
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 === '/auth' ||
|
||||
location.pathname.startsWith('/lobby') ||
|
||||
location.pathname.startsWith('/game')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
trackEvent(location.pathname, document.referrer);
|
||||
}, [location.pathname, navigationType]);
|
||||
|
||||
return (
|
||||
@@ -89,10 +103,12 @@ 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 />} />
|
||||
<Route path="/history" element={<History />} />
|
||||
<Route path="/donate" element={<Donate />} />
|
||||
<Route path="/lobby/:gid" element={<Lobby />} />
|
||||
<Route path="/game/:gid" element={<GameTable />} />
|
||||
<Route path="/admin" element={<AdminLayout />}>
|
||||
@@ -101,6 +117,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,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);
|
||||
}
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -3,11 +3,15 @@ import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { setupSocketListeners, socket, setAuthToken } from './lib/socket';
|
||||
import { trackLanding } from './lib/track';
|
||||
|
||||
setupSocketListeners();
|
||||
// Carry the stored session token into the handshake so the server auto-logs us in.
|
||||
setAuthToken(localStorage.getItem('bridzik_token'));
|
||||
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(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, 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';
|
||||
@@ -19,6 +20,15 @@ export default function Auth() {
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim() || code.trim().length < 6) return;
|
||||
@@ -30,6 +40,7 @@ export default function Auth() {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
remember(username);
|
||||
trackEvent('register');
|
||||
emit.registerAccount(username.trim());
|
||||
};
|
||||
|
||||
@@ -45,6 +56,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 +178,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
@@ -32,6 +38,9 @@ export default function GameList() {
|
||||
<button onClick={() => navigate('/history')} className="text-gold hover:text-gold-bright">
|
||||
História
|
||||
</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">
|
||||
Odhlásiť
|
||||
</button>
|
||||
@@ -85,7 +94,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>
|
||||
|
||||
|
||||
@@ -20,9 +20,11 @@ interface DailyStats {
|
||||
completion_rate: number | null;
|
||||
avg_game_duration_minutes: number | null;
|
||||
total_players: number;
|
||||
unconfirmed_players: number;
|
||||
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 +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() {
|
||||
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 +147,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>;
|
||||
@@ -132,6 +156,7 @@ export default function AdminStats() {
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className={`flex gap-3 ${desktop ? '' : 'flex-wrap'}`}>
|
||||
<SummaryCard label="Hráči celkom" value={String(data.total_players)} />
|
||||
<SummaryCard label="Nedokončené registrácie" value={String(data.unconfirmed_players)} />
|
||||
<SummaryCard
|
||||
label="Dokončené hry"
|
||||
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" />
|
||||
</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}
|
||||
|
||||
+35
-10
@@ -57,24 +57,37 @@ class HistoryCase(unittest.TestCase):
|
||||
for _ in range(n):
|
||||
username = "u_" + uuid.uuid4().hex[:8]
|
||||
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"])
|
||||
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):
|
||||
username = "alice_" + uuid.uuid4().hex[:6]
|
||||
data = run(auth.register_account(username))
|
||||
self.assertIn("otpauth_uri", data)
|
||||
|
||||
# Zle meno je obsadene
|
||||
with self.assertRaises(auth.AuthError):
|
||||
run(auth.register_account(username))
|
||||
# Nedokoncena registracia: opakovany register vyda NOVY secret
|
||||
data = 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()
|
||||
ident = run(auth.login(username, code))
|
||||
ident = run(auth.confirm_account(username, code))
|
||||
self.assertEqual(ident["username"], username)
|
||||
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
|
||||
resolved = run(auth.player_by_token(ident["token"]))
|
||||
self.assertEqual(resolved["player_id"], ident["player_id"])
|
||||
@@ -83,29 +96,41 @@ class HistoryCase(unittest.TestCase):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
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):
|
||||
username = "bob_" + uuid.uuid4().hex[:6]
|
||||
data = run(auth.register_account(username))
|
||||
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
|
||||
|
||||
for _ in range(auth._LOGIN_ATTEMPT_LIMIT):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
run(auth.login(username, "000000"))
|
||||
|
||||
# Lockout odmietne aj spravny kod, kym neubehne okno
|
||||
code = pyotp.TOTP(data["secret"]).now()
|
||||
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):
|
||||
username = "carol_" + uuid.uuid4().hex[:6]
|
||||
data = run(auth.register_account(username))
|
||||
run(auth.confirm_account(username, pyotp.TOTP(data["secret"]).now()))
|
||||
|
||||
for _ in range(auth._LOGIN_ATTEMPT_LIMIT - 1):
|
||||
with self.assertRaises(auth.AuthError):
|
||||
run(auth.login(username, "000000"))
|
||||
|
||||
code = pyotp.TOTP(data["secret"]).now()
|
||||
ident = run(auth.login(username, code))
|
||||
ident = run(auth.login(username, self._next_step_code(data["secret"])))
|
||||
self.assertEqual(ident["username"], username)
|
||||
self.assertNotIn(username, auth._failed_attempts)
|
||||
|
||||
@@ -241,7 +266,7 @@ class HistoryCase(unittest.TestCase):
|
||||
def test_auth_token_stored_hashed_not_plaintext(self):
|
||||
username = "erin_" + uuid.uuid4().hex[:6]
|
||||
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 with async_session() as session:
|
||||
|
||||
+216
-5
@@ -58,13 +58,13 @@ class StatsCase(unittest.TestCase):
|
||||
for _ in range(n):
|
||||
username = "u_" + uuid.uuid4().hex[:8]
|
||||
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"])
|
||||
return ids
|
||||
|
||||
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,208 @@ 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; "/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):
|
||||
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"]
|
||||
@@ -125,8 +319,25 @@ class StatsCase(unittest.TestCase):
|
||||
self.assertGreaterEqual(sum(data["games_per_day"].values()), 1)
|
||||
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):
|
||||
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