Merge branch 'fixy'

This commit is contained in:
tim
2026-07-04 14:05:19 +02:00
18 changed files with 591 additions and 162 deletions
+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. -->
+50 -11
View File
@@ -1,3 +1,4 @@
import asyncio
import hmac
import json
import os
@@ -5,6 +6,7 @@ import time
import uuid
from collections import defaultdict
from json import JSONEncoder
from urllib.parse import parse_qs
import socketio
@@ -102,6 +104,9 @@ async def _handle_track(scope, receive, send):
data = {}
headers = dict(scope.get("headers") or [])
user_agent = headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300]
# player_id sa sem nikdy nedava z klienta (nedovereny/spoofovatelny vstup na
# neautentifikovanom endpointe) -- pripaja sa len server-side na evente
# "login" (pozri handler @sio.on("login")).
await stats_module.record_pageview(
path=str(data.get("path", ""))[:200],
referrer=str(data.get("referrer", ""))[:300],
@@ -119,7 +124,9 @@ async def _handle_admin_stats(scope, send):
if not _admin_authorized(scope):
_register_admin_failure(ip)
return await _send_text(send, 403, b"forbidden")
data = await stats_module.get_daily_stats()
query = parse_qs((scope.get("query_string") or b"").decode("utf-8", "ignore"))
logged_in_only = query.get("logged_in", ["0"])[0] == "1"
data = await stats_module.get_daily_stats(logged_in_only=logged_in_only)
await _send_text(send, 200, json.dumps(data).encode(), b"application/json")
@@ -273,19 +280,37 @@ 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:
await sio.emit(
"player_connection",
{"order": player.order, "connected": False},
room=game.gid,
)
asyncio.create_task(_cleanup_abandoned_lobby(game.gid))
await sio.emit(
"player_connection",
{"order": player.order, "connected": False},
room=game.gid,
)
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)
async def _record_login_event(sid: str, player_id: int) -> None:
scope = (sio.get_environ(sid) or {}).get("asgi.scope", {})
headers = dict(scope.get("headers") or [])
await stats_module.record_pageview(
path="login",
referrer="",
user_agent=headers.get(b"user-agent", b"").decode("utf-8", "ignore")[:300],
ip=_client_ip(scope),
player_id=player_id,
)
@sio.on("confirm_account")
async def confirm_account(sid, username, code):
try:
@@ -370,6 +407,7 @@ async def confirm_account(sid, username, code):
except AuthError as exc:
return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
await _record_login_event(sid, identity["player_id"])
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
@@ -380,6 +418,7 @@ async def login(sid, username, code):
except AuthError as exc:
return await send_error(sid, str(exc))
accounts[sid] = {"player_id": identity["player_id"], "username": identity["username"]}
await _record_login_event(sid, identity["player_id"])
await sio.emit("login", {"player": accounts[sid], "token": identity["token"]}, to=sid)
+169 -68
View File
@@ -19,8 +19,9 @@ _geoip_load_attempted = False
def _country_for_ip(ip: str) -> str:
"""ISO kod krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo "" ak nie je
dostupny subor alebo sa IP neda rozlusit (privatna/lokalna adresa a pod.)."""
"""Cely anglicky nazov krajiny z lokalneho .mmdb (GEOIP_DB_PATH), alebo ""
ak nie je dostupny subor alebo sa IP neda rozlusit (privatna/lokalna
adresa a pod.)."""
global _geoip_reader, _geoip_load_attempted
if not ip:
return ""
@@ -33,12 +34,36 @@ def _country_for_ip(ip: str) -> str:
return ""
_geoip_reader = geoip2.database.Reader(path)
try:
return _geoip_reader.country(ip).country.iso_code or ""
return _geoip_reader.country(ip).country.name or ""
except (geoip2.errors.AddressNotFoundError, ValueError):
return ""
async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "") -> None:
# Cesty s dynamickym ID segmentom -- do statistik sa uklada len prefix, aby sa
# navstevy neroztriestili na /lobby/<gid>, /game/<gid>... (kazda hra inak max 4x).
_DYNAMIC_PATH_PREFIXES = ("/lobby", "/game")
# Tieto (po normalizacii) sa vobec nezaznamenavaju -- vysoka frekvencia (kazda
# akcia v hre) bez analytickej hodnoty. Ostava len /auth, /history a pomenovane
# eventy (napr. "rules_view", "login"), ktore sem nespadaju.
_SKIPPED_PATHS = frozenset({"/", "/lobby", "/game"})
def _normalize_path(path: str) -> str:
for prefix in _DYNAMIC_PATH_PREFIXES:
if path == prefix or path.startswith(prefix + "/"):
return prefix
return path
async def record_pageview(
path: str, referrer: str, user_agent: str, ip: str = "", player_id: int | None = None
) -> None:
"""Zapise navstevu URL cesty ALEBO pomenovany event -- rovnaky stlpec `path`
rozlisuje oboje podla toho, ci zacina "/" (pozri PageView.path)."""
normalized = _normalize_path(path)
if normalized in _SKIPPED_PATHS:
return
ua = parse_ua(user_agent)
device_type = (
"bot" if ua.is_bot else "mobile" if ua.is_mobile else "tablet" if ua.is_tablet else "pc"
@@ -46,7 +71,8 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "
async with async_session() as session:
session.add(
PageView(
path=path,
path=normalized,
player_id=player_id,
referrer=referrer,
user_agent=user_agent,
browser=ua.browser.family[:40],
@@ -61,35 +87,106 @@ async def record_pageview(path: str, referrer: str, user_agent: str, ip: str = "
_DAYS_WINDOW = 30 # kazdy "za den" graf/rozklad zobrazuje rovnake okno
# Identita navstevnika = IP + User-Agent (rovnaky pristup ako Plausible/
# GoatCounter): odlisi dvoch ludi za jednym NAT-om s roznym prehliadacom/
# zariadenim. Dvoch s uplne identickym UA neodlisi nic bez cookies.
_visitor_id = PageView.ip + "|" + PageView.user_agent
async def _pageviews_by_day_and(session, column) -> dict[str, dict[str, int]]:
async def _pageviews_by_day_and(session, column, logged_in_only=False) -> dict[str, dict[str, int]]:
"""Denne navstevy rozdelene podla danej dimenzie (device_type/browser/os),
napr. {"2026-07-01": {"pc": 3, "mobile": 1}, ...} -- pre prepinatelny graf.
Orezane na _DAYS_WINDOW dni, rovnako ako pageviews_per_day (a ostatne denne
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu."""
grafy), aby prepnutie medzi dimenziami neroztiahlo graf na celu historiu.
Scope "logged_in": kazdy login sa pocita samostatne (rovnako ako
_login_event_counts), aby graf sedel s cislami v BreakdownTable nizsie.
Scope "all": pocita sa "navstevnicky den" (rovnako ako _daily_unique_by),
z toho isteho dovodu -- inak by graf (klikova statistika) nesedel s
cislami dole (navstevnicka statistika)."""
vday = func.date(PageView.created_at)
recent_days = (
select(vday.label("day"))
.distinct()
.order_by(vday.desc())
.limit(_DAYS_WINDOW)
.subquery()
)
rows = (
await session.execute(
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(vday.in_(select(recent_days.c.day)))
.group_by(vday, column)
.order_by(vday.desc())
.where(PageView.path == "login")
)
).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]] = {}
for r in rows:
nested.setdefault(str(r.day), {})[r.cat] = r.n
return nested
async def get_daily_stats() -> dict:
async def _daily_unique_by(session, column, exclude_empty=False) -> list:
"""Rozklad podla dimenzie (browser/os/...) pre anonymnu navstevnost, kde
jednotka nie je klik ale "navstevnicky den": ten isty navstevnik (IP+UA)
sa v ramci jedneho dna pocita raz, na dalsi den znova. Sedi tak so suctom
grafu visitors_per_day. Pre scope "logged_in" sa nepouziva -- tam ma kazde
prihlasenie vahu 1x (viz _login_event_counts), aby to sedelo s
pageviews_per_day ("Ked sa 2x prihlasi ten isty user, chcem to mat ako 2x").
Portable cez SQLite aj Postgres: najprv DISTINCT (den, kategoria, navstevnik)
v subquery, potom GROUP BY kategoria."""
vday = func.date(PageView.created_at)
inner = select(
vday.label("day"), column.label("cat"), _visitor_id.label("visitor")
).distinct()
if exclude_empty:
inner = inner.where(column != "")
sub = inner.subquery()
return (
await session.execute(
select(sub.c.cat, func.count().label("n"))
.group_by(sub.c.cat)
.order_by(func.count().desc())
)
).all()
async def _login_event_counts(session, column, exclude_empty=False) -> list:
"""Rozklad podla dimenzie pocitany priamo z poctu login-eventov (kazdy
riadok PageView s path == "login" sa pocita samostatne) -- na rozdiel od
_daily_unique_by nededuplikuje podla navstevnika/dna, takze opakovane
prihlasenie toho isteho hraca v ten isty den sa prejavi ako 2, presne
ako v pageviews_per_day."""
q = select(column.label("cat"), func.count().label("n")).where(PageView.path == "login")
if exclude_empty:
q = q.where(column != "")
return (
await session.execute(q.group_by(column).order_by(func.count().desc()))
).all()
async def get_daily_stats(logged_in_only: bool = False) -> dict:
"""logged_in_only obmedzuje traffic-analyticke widgety (PageView) na
zaznamy z eventu "login" (jediny event, ktory nesie player_id -- bezne
beacony ho neposielaju vobec). Herne metriky (games/players/rounds) su
uz zo svojej podstaty vzdy o prihlasenych uctoch, prepinac sa ich netyka.
top_paths ostava vzdy pocitane zo vsetkych navstev bez ohladu na scope --
najnavstevovanejsie stranky maju zmysel len ako celok."""
async with async_session() as session:
# func.date() (not cast(..., Date)) -- the `date()` SQL function is portable
# across SQLite and Postgres and returns a plain string/date value without
@@ -151,23 +248,31 @@ async def get_daily_stats() -> dict:
).all()
vday = func.date(PageView.created_at)
pageview_q = select(vday.label("day"), func.count().label("n"))
if logged_in_only:
# "Navstevnici" v scope Prihlaseni = unikatni HRACI (player_id) za
# den, nie unikatne IP+UA -- to je presny pocet skutocnych uctov.
pageview_q = pageview_q.where(PageView.path == "login")
visitor_q = (
select(vday.label("day"), func.count(func.distinct(PageView.player_id)).label("n"))
.where(PageView.path == "login")
)
else:
visitor_q = select(vday.label("day"), func.count(func.distinct(_visitor_id)).label("n"))
pageview_rows = (
await session.execute(
select(vday.label("day"), func.count().label("n"))
.group_by(vday)
.order_by(vday.desc())
.limit(_DAYS_WINDOW)
pageview_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
)
).all()
top_referrers = (
# Unikatni navstevnici za den (distinct IP+UA v ramci dna; ten isty
# navstevnik sa na dalsi den pocita znova).
visitor_rows = (
await session.execute(
select(PageView.referrer, func.count().label("n"))
.where(PageView.referrer != "")
.group_by(PageView.referrer)
.order_by(func.count().desc())
.limit(20)
visitor_q.group_by(vday).order_by(vday.desc()).limit(_DAYS_WINDOW)
)
).all()
# Top stranky vzdy zo vsetkych navstev -- scope prepinac sa ich netyka.
top_paths = (
await session.execute(
select(PageView.path, func.count().label("n"))
@@ -177,39 +282,34 @@ async def get_daily_stats() -> dict:
)
).all()
browsers = (
await session.execute(
select(PageView.browser, func.count().label("n"))
.group_by(PageView.browser)
.order_by(func.count().desc())
)
).all()
os_rows = (
await session.execute(
select(PageView.os, func.count().label("n"))
.group_by(PageView.os)
.order_by(func.count().desc())
)
).all()
device_rows = (
await session.execute(
select(PageView.device_type, func.count().label("n"))
.group_by(PageView.device_type)
.order_by(func.count().desc())
)
).all()
country_rows = (
await session.execute(
select(PageView.country, func.count().label("n"))
.where(PageView.country != "")
.group_by(PageView.country)
.order_by(func.count().desc())
)
).all()
if logged_in_only:
# Kazdy login sa pocita samostatne (nededuplikovane) -- sedi to s
# pageviews_per_day, kde opakovane prihlasenie toho isteho hraca
# v ten isty den ma tiez pridat 2, nie 1.
top_referrers = (await _login_event_counts(session, PageView.referrer, exclude_empty=True))[:20]
browsers = await _login_event_counts(session, PageView.browser)
os_rows = await _login_event_counts(session, PageView.os)
device_rows = await _login_event_counts(session, PageView.device_type)
country_rows = await _login_event_counts(session, PageView.country, exclude_empty=True)
else:
# Zlozenie anonymnej navstevnosti sa pocita v "navstevnickych
# dnoch" (den+IP raz), nie v klikoch -- jeden aktivny hrac tak
# neprevazi tabulky.
top_referrers = (await _daily_unique_by(session, PageView.referrer, exclude_empty=True))[:20]
browsers = await _daily_unique_by(session, PageView.browser)
os_rows = await _daily_unique_by(session, PageView.os)
device_rows = await _daily_unique_by(session, PageView.device_type)
country_rows = await _daily_unique_by(session, PageView.country, exclude_empty=True)
pageviews_per_day_by_device = await _pageviews_by_day_and(session, PageView.device_type)
pageviews_per_day_by_browser = await _pageviews_by_day_and(session, PageView.browser)
pageviews_per_day_by_os = await _pageviews_by_day_and(session, PageView.os)
pageviews_per_day_by_device = await _pageviews_by_day_and(
session, PageView.device_type, logged_in_only=logged_in_only
)
pageviews_per_day_by_browser = await _pageviews_by_day_and(
session, PageView.browser, logged_in_only=logged_in_only
)
pageviews_per_day_by_os = await _pageviews_by_day_and(
session, PageView.os, logged_in_only=logged_in_only
)
return {
"games_per_day": {str(r.day): r.n for r in game_rows},
@@ -220,13 +320,14 @@ async def get_daily_stats() -> dict:
"peak_hours": {int(r.h): r.n for r in peak_hours},
"rounds_per_day": {str(r.day): r.n for r in rounds_rows},
"pageviews_per_day": {str(r.day): r.n for r in pageview_rows},
"visitors_per_day": {str(r.day): r.n for r in visitor_rows},
"pageviews_per_day_by_device": pageviews_per_day_by_device,
"pageviews_per_day_by_browser": pageviews_per_day_by_browser,
"pageviews_per_day_by_os": pageviews_per_day_by_os,
"top_referrers": {r.referrer: r.n for r in top_referrers},
"top_referrers": {r.cat: r.n for r in top_referrers},
"top_paths": {r.path: r.n for r in top_paths},
"browsers": {r.browser: r.n for r in browsers},
"operating_systems": {r.os: r.n for r in os_rows},
"device_types": {r.device_type: r.n for r in device_rows},
"countries": {r.country: r.n for r in country_rows},
"browsers": {r.cat: r.n for r in browsers},
"operating_systems": {r.cat: r.n for r in os_rows},
"device_types": {r.cat: r.n for r in device_rows},
"countries": {r.cat: r.n for r in country_rows},
}
+7 -1
View File
@@ -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()
)
+30 -21
View File
@@ -1,15 +1,18 @@
import { useEffect } from 'react';
import { lazy, Suspense, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useNavigate, useLocation, useNavigationType } from 'react-router-dom';
import { useGameStore } from './store/gameStore';
import { socket, emit } from './lib/socket';
import { trackEvent } from './lib/track';
import type { MyPlayer } from './types';
import GameList from './pages/GameList';
import Lobby from './pages/Lobby';
import GameTable from './pages/GameTable';
import Auth from './pages/Auth';
import History from './pages/History';
import AdminLayout from './pages/admin/AdminLayout';
import AdminStats from './pages/admin/AdminStats';
// Admin pulls in recharts — lazy-load it so players never download that chunk.
const AdminLayout = lazy(() => import('./pages/admin/AdminLayout'));
const AdminStats = lazy(() => import('./pages/admin/AdminStats'));
function AppInner() {
const navigate = useNavigate();
@@ -71,15 +74,19 @@ function AppInner() {
// stats), not a page the user actually navigated to, so they'd otherwise
// inflate the count with one extra row per redirect hop. Skip /admin itself
// too -- that's the dashboard viewing its own traffic, not player usage.
// Skip /, /lobby, /game -- high-frequency in-game navigation with no
// analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS).
useEffect(() => {
if (navigationType === 'REPLACE') return;
if (location.pathname.startsWith('/admin')) return;
fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: location.pathname, referrer: document.referrer }),
keepalive: true,
}).catch(() => {});
if (
location.pathname === '/' ||
location.pathname.startsWith('/lobby') ||
location.pathname.startsWith('/game')
) {
return;
}
trackEvent(location.pathname, document.referrer);
}, [location.pathname, navigationType]);
return (
@@ -89,18 +96,20 @@ function AppInner() {
{error}
</div>
)}
<Routes>
<Route path="/auth" element={<Auth />} />
<Route path="/" element={<GameList />} />
<Route path="/history" element={<History />} />
<Route path="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="stats" replace />} />
<Route path="stats" element={<AdminStats />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<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="/lobby/:gid" element={<Lobby />} />
<Route path="/game/:gid" element={<GameTable />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="stats" replace />} />
<Route path="stats" element={<AdminStats />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</>
);
}
+2 -4
View File
@@ -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 (
+9
View File
@@ -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;
}
+3
View File
@@ -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}`);
});
}
+12
View File
@@ -0,0 +1,12 @@
/** Fire-and-forget event/pageview beacon for self-hosted analytics; must
* never affect the app (network errors are swallowed). player_id is only
* ever attached server-side to the "login" event -- ordinary beacons don't
* carry it. */
export function trackEvent(path: string, referrer = '') {
fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path, referrer }),
keepalive: true,
}).catch(() => {});
}
+4 -4
View File
@@ -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);
}, []);
+7 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { QRCodeSVG } from 'qrcode.react';
import { useGameStore } from '../store/gameStore';
import { emit } from '../lib/socket';
import { trackEvent } from '../lib/track';
import RulesModal from '../components/RulesModal';
type Mode = 'login' | 'register';
@@ -45,6 +46,11 @@ export default function Auth() {
setRegistration(null);
};
const handleShowRules = () => {
setShowRules(true);
trackEvent('rules_view');
};
return (
<div className="max-w-sm mx-auto p-4 pt-12 min-h-screen">
<h1 className="font-serif text-4xl text-center text-gold tracking-wide mb-1">Bridžik</h1>
@@ -162,7 +168,7 @@ export default function Auth() {
<div className="mt-10 flex items-center gap-3">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-gold/20" />
<button
onClick={() => setShowRules(true)}
onClick={handleShowRules}
className="uppercase tracking-[.13em] text-[10px] text-green-dim hover:text-gold transition-colors"
>
Pravidlá hry
+7 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useGameStore } from '../store/gameStore';
import { emit, socket, setAuthToken } from '../lib/socket';
import { trackEvent } from '../lib/track';
import NameModal from '../components/NameModal';
import RulesModal from '../components/RulesModal';
@@ -12,6 +13,11 @@ export default function GameList() {
const [showCreate, setShowCreate] = useState(false);
const [showRules, setShowRules] = useState(false);
const handleShowRules = () => {
setShowRules(true);
trackEvent('rules_view');
};
const handleLogout = () => {
localStorage.removeItem('bridzik_token');
localStorage.removeItem('bridzik_player');
@@ -85,7 +91,7 @@ export default function GameList() {
</button>
<button
onClick={() => setShowRules(true)}
onClick={handleShowRules}
className="w-full py-2 rounded-xl border border-gold/20 text-sm text-green-score hover:border-gold/50 transition-colors"
>
Pravidlá hry
+16 -30
View File
@@ -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>
);
+1 -1
View File
@@ -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}>
+21 -8
View File
@@ -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>
+32 -2
View File
@@ -23,6 +23,7 @@ interface DailyStats {
peak_hours: Record<string, number>;
rounds_per_day: Record<string, number>;
pageviews_per_day: Record<string, number>;
visitors_per_day: Record<string, number>;
pageviews_per_day_by_device: Record<string, Record<string, number>>;
pageviews_per_day_by_browser: Record<string, Record<string, number>>;
pageviews_per_day_by_os: Record<string, Record<string, number>>;
@@ -100,15 +101,37 @@ function BreakdownTable({ title, data }: { title: string; data: Record<string, n
);
}
type Scope = 'all' | 'logged_in';
function ScopeToggle({ scope, onChange }: { scope: Scope; onChange: (s: Scope) => void }) {
return (
<div className="flex rounded-lg overflow-hidden border border-gold/20 self-start">
{(['all', 'logged_in'] as Scope[]).map((s) => (
<button
key={s}
onClick={() => onChange(s)}
className={`px-3 py-1.5 text-xs font-serif tracking-wide transition-colors ${
scope === s ? 'bg-gold text-table' : 'bg-header text-green-dim hover:text-gold'
}`}
>
{s === 'all' ? 'Všetci' : 'Prihlásení'}
</button>
))}
</div>
);
}
export default function AdminStats() {
const token = useAdminToken();
const desktop = useIsDesktop();
const [data, setData] = useState<DailyStats | null>(null);
const [error, setError] = useState<string | null>(null);
const [scope, setScope] = useState<Scope>('all');
useEffect(() => {
let cancelled = false;
fetch('/api/admin/stats', { headers: { Authorization: `Bearer ${token}` } })
const qs = scope === 'logged_in' ? '?logged_in=1' : '';
fetch(`/api/admin/stats${qs}`, { headers: { Authorization: `Bearer ${token}` } })
.then((res) => {
if (res.status === 403) throw new Error('Neplatný token.');
if (!res.ok) throw new Error('Chyba pri načítaní štatistík.');
@@ -123,7 +146,7 @@ export default function AdminStats() {
return () => {
cancelled = true;
};
}, [token]);
}, [token, scope]);
if (error) return <p className="text-red-400 text-sm">{error}</p>;
if (!data) return <p className="text-green-dim text-sm">Načítavam...</p>;
@@ -149,8 +172,15 @@ export default function AdminStats() {
<TimeSeriesChart title="Hodiny s najvyššou aktivitou" data={data.peak_hours} kind="bar" />
</div>
{/* Prepinac sa tyka len traffic-analytickych widgetov nizsie (PageView) --
herne metriky vyssie su uz vzdy o prihlasenych uctoch. */}
<ScopeToggle scope={scope} onChange={setScope} />
<PageviewsChart
total={data.pageviews_per_day}
totalLabel={scope === 'logged_in' ? 'Prihlásení' : 'Zobrazenia'}
visitors={data.visitors_per_day}
visitorsLabel={scope === 'logged_in' ? 'Unikátní' : 'Návštevníci'}
byDevice={data.pageviews_per_day_by_device}
byBrowser={data.pageviews_per_day_by_browser}
byOs={data.pageviews_per_day_by_os}
+22 -6
View File
@@ -25,18 +25,32 @@ const PALETTE = ['#c9a84c', '#d8cba6', '#9c906c', '#f0d060', '#c2b58c', '#8a8064
interface Props {
total: Record<string, number>;
totalLabel?: string;
visitors: Record<string, number>;
visitorsLabel?: string;
byDevice: Record<string, Record<string, number>>;
byBrowser: Record<string, Record<string, number>>;
byOs: Record<string, Record<string, number>>;
}
export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Props) {
export default function PageviewsChart({
total,
totalLabel = 'Zobrazenia',
visitors,
visitorsLabel = 'Návštevníci',
byDevice,
byBrowser,
byOs,
}: Props) {
const [dimension, setDimension] = useState<Dimension>('total');
const { rows, categories } = useMemo(() => {
if (dimension === 'total') {
const days = Object.keys(total).sort();
return { rows: days.map((day) => ({ day, n: total[day] })), categories: ['n'] };
const days = [...new Set([...Object.keys(total), ...Object.keys(visitors)])].sort();
return {
rows: days.map((day) => ({ day, n: total[day] ?? 0, visitors: visitors[day] ?? 0 })),
categories: ['n', 'visitors'],
};
}
const byDay = dimension === 'device' ? byDevice : dimension === 'browser' ? byBrowser : byOs;
const days = Object.keys(byDay).sort();
@@ -47,7 +61,9 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
return row;
});
return { rows, categories };
}, [dimension, total, byDevice, byBrowser, byOs]);
}, [dimension, total, visitors, byDevice, byBrowser, byOs]);
const lineName = (c: string) => (c === 'n' ? totalLabel : c === 'visitors' ? visitorsLabel : c);
return (
<div className="bg-header border border-[#142018] rounded-xl p-4">
@@ -73,13 +89,13 @@ export default function PageviewsChart({ total, byDevice, byBrowser, byOs }: Pro
<XAxis dataKey="day" tick={{ fill: '#9c906c', fontSize: 10 }} />
<YAxis tick={{ fill: '#9c906c', fontSize: 10 }} allowDecimals={false} />
<Tooltip contentStyle={{ background: '#070c09', border: '1px solid #142018' }} />
{dimension !== 'total' && <Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />}
<Legend wrapperStyle={{ fontSize: 11, color: '#9c906c' }} />
{categories.map((c, i) => (
<Line
key={c}
type="monotone"
dataKey={c}
name={dimension === 'total' ? 'Návštevy' : c}
name={lineName(c)}
stroke={PALETTE[i % PALETTE.length]}
strokeWidth={2}
dot={false}
+183 -4
View File
@@ -64,7 +64,7 @@ class StatsCase(unittest.TestCase):
def test_record_pageview_parses_user_agent(self):
run(stats.record_pageview(
path="/", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
path="/history", referrer="https://example.com", user_agent=CHROME_UA, ip="203.0.113.5",
))
data = run(stats.get_daily_stats())
@@ -73,7 +73,7 @@ class StatsCase(unittest.TestCase):
self.assertIn("Windows", data["operating_systems"])
self.assertGreaterEqual(data["device_types"].get("pc", 0), 1)
self.assertGreaterEqual(data["top_referrers"].get("https://example.com", 0), 1)
self.assertGreaterEqual(data["top_paths"].get("/", 0), 1)
self.assertGreaterEqual(data["top_paths"].get("/history", 0), 1)
# Bez GEOIP_DB_PATH (v testoch nenastaveny) sa krajina jednoducho nerozlusi,
# ale IP sa uz do page_views ulozi.
@@ -102,14 +102,193 @@ class StatsCase(unittest.TestCase):
self.assertEqual(stats._country_for_ip("203.0.113.5"), "")
self.assertEqual(stats._country_for_ip(""), "")
def test_dynamic_path_segments_normalized(self):
self.assertEqual(stats._normalize_path("/lobby/abc-123"), "/lobby")
self.assertEqual(stats._normalize_path("/game/abc-123"), "/game")
self.assertEqual(stats._normalize_path("/lobby"), "/lobby")
self.assertEqual(stats._normalize_path("/history"), "/history")
# /gameXYZ nie je /game/<id> -- nesmie sa orezat
self.assertEqual(stats._normalize_path("/gamex"), "/gamex")
def test_skipped_paths_not_recorded(self):
# "/", "/lobby" a "/game" (aj s dynamickym ID) sa vobec nezapisuju --
# vysoka frekvencia bez analytickej hodnoty (api/stats.py _SKIPPED_PATHS).
async def _count():
from sqlalchemy import func, select as sa_select
from db.db import async_session
from db.models import PageView
async with async_session() as session:
return (
await session.execute(sa_select(func.count()).select_from(PageView))
).scalar()
before = run(_count())
for path in ("/", "/lobby", "/lobby/abc-123", "/game", "/game/xyz-789"):
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA))
after = run(_count())
self.assertEqual(after, before)
def test_event_path_stores_player_id(self):
# Konvencia: nazov bez "/" na zaciatku = pomenovany event (napr. "login"
# zapisovany zo socket handlera po uspesnom prihlaseni), nie URL cesta.
ids = self._make_players(n=1)
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0],
))
async def _last():
from sqlalchemy import select as sa_select
from db.db import async_session
from db.models import PageView
async with async_session() as session:
row = (
await session.execute(
sa_select(PageView).order_by(PageView.id.desc()).limit(1)
)
).scalar_one()
return row.path, row.player_id
path, player_id = run(_last())
self.assertEqual(path, "login")
self.assertEqual(player_id, ids[0])
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("login", 0), 1)
def test_rules_view_event_recorded_without_player_id(self):
run(stats.record_pageview(path="rules_view", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
self.assertGreaterEqual(data["top_paths"].get("rules_view", 0), 1)
def test_logged_in_only_scope_counts_login_events_only(self):
# Bezne beacony uz neposielaju player_id vobec -- scope "logged_in"
# preto filtruje priamo podla path == "login", nie podla pritomnosti
# player_id. Obycajna navsteva (aj s player_id) sa do neho nepocita.
ids = self._make_players(n=1)
ip = "203.0.113.99"
before_all = run(stats.get_daily_stats(logged_in_only=False))
before_logged_in = run(stats.get_daily_stats(logged_in_only=True))
all_before = sum(before_all["pageviews_per_day"].values())
logged_in_before = sum(before_logged_in["pageviews_per_day"].values())
# Bezna navsteva -- ma pribudnut len vo "vsetci", aj keby mala player_id.
run(stats.record_pageview(
path="/history", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
))
# Login event -- ma pribudnut v oboch.
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip=ip, player_id=ids[0],
))
after_all = run(stats.get_daily_stats(logged_in_only=False))
after_logged_in = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(after_all["pageviews_per_day"].values()), all_before + 2)
self.assertEqual(sum(after_logged_in["pageviews_per_day"].values()), logged_in_before + 1)
def test_logged_in_only_breakdown_tables_count_each_login_not_deduped(self):
# browsers/os/device_types/top_referrers/countries pouzivaju v scope
# "logged_in" priamy pocet login-eventov, nie unikatny navstevnicky
# den -- 2x prihlasenie tym istym prehliadacom/IP v ten isty den sa
# ma prejavit ako 2, rovnako ako v grafe pageviews_per_day.
ids = self._make_players(n=1)
before = run(stats.get_daily_stats(logged_in_only=True))
before_browsers = sum(before["browsers"].values())
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
run(stats.record_pageview(path="login", referrer="", user_agent=CHROME_UA, player_id=ids[0]))
data = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(data["browsers"].values()), before_browsers + 2)
def test_logged_in_visitors_per_day_counts_distinct_players_not_ip(self):
# V scope "logged_in" ma visitors_per_day znamenat unikatnych HRACOV
# (player_id) za den, nie unikatne IP+UA -- 2x prihlasenie toho
# isteho hraca (aj z inej IP/prehliadaca) sa ma pocitat len raz, na
# rozdiel od pageviews_per_day, kde sa kazdy login pocita zvlast.
ids = self._make_players(n=2)
before = run(stats.get_daily_stats(logged_in_only=True))
before_visitors = sum(before["visitors_per_day"].values())
before_pageviews = sum(before["pageviews_per_day"].values())
# Ten isty hrac, 2x prihlasenie z roznych "zariadeni" (rozne IP/UA).
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.10", player_id=ids[0],
))
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.11", player_id=ids[0],
))
# Iny hrac, 1x prihlasenie.
run(stats.record_pageview(
path="login", referrer="", user_agent=CHROME_UA, ip="203.0.113.12", player_id=ids[1],
))
data = run(stats.get_daily_stats(logged_in_only=True))
self.assertEqual(sum(data["pageviews_per_day"].values()), before_pageviews + 3)
self.assertEqual(sum(data["visitors_per_day"].values()), before_visitors + 2)
def test_visitors_counted_once_per_day_per_ip(self):
# 3 kliky z tej istej IP v ten isty den -> pageviews +3, visitors iba 1.
ip = "198.51.100.77"
for path in ("/history", "/history", "/history"):
run(stats.record_pageview(path=path, referrer="", user_agent=CHROME_UA, ip=ip))
data = run(stats.get_daily_stats())
today = next(iter(data["visitors_per_day"]))
# V testovej DB su vsetky zaznamy z dneska; unikatnych IP je menej nez klikov.
self.assertLess(data["visitors_per_day"][today], data["pageviews_per_day"][today])
# Tabulky zlozenia publika pocitaju den+IP raz -- 3 kliky tej istej IP
# nesmu zdvihnut "Chrome" o 3. Overime, ze pocet je mensi nez pocet klikov.
self.assertLess(data["browsers"].get("Chrome", 0), sum(data["pageviews_per_day"].values()))
def test_same_ip_different_user_agent_counts_as_two_visitors(self):
ip = "192.0.2.44"
firefox_ua = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:126.0) "
"Gecko/20100101 Firefox/126.0"
)
before = run(stats.get_daily_stats())
today_before = sum(before["visitors_per_day"].values())
# Ta ista IP, dva rozne prehliadace -> 2 navstevnici (kazdy klikne 2x).
for _ in range(2):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
run(stats.record_pageview(path="/history", referrer="", user_agent=firefox_ua, ip=ip))
after = run(stats.get_daily_stats())
self.assertEqual(sum(after["visitors_per_day"].values()), today_before + 2)
def test_pageviews_per_day_breakdown_by_dimension(self):
run(stats.record_pageview(path="/", referrer="", user_agent=CHROME_UA))
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
today = next(iter(data["pageviews_per_day_by_device"]))
self.assertGreaterEqual(data["pageviews_per_day_by_device"][today].get("pc", 0), 1)
self.assertIn("Chrome", data["pageviews_per_day_by_browser"][today])
self.assertIn("Windows", data["pageviews_per_day_by_os"][today])
def test_all_scope_chart_breakdown_matches_composition_tables(self):
# Bug hlaseny uzivatelom: v scope "vsetci" graf (pageviews_per_day_by_*)
# pocital kazdy klik zvlast, zatial co tabulky nizsie (browsers/os/
# device_types) pocitali unikatny "navstevnicky den" -- cisla si tak
# nesedeli. Oboje ma teraz rovnaku dedup logiku (jeden navstevnik +
# kategoria + den = 1), takze sucty musia byt zhodne.
ip = "203.0.113.50"
for _ in range(3):
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA, ip=ip))
data = run(stats.get_daily_stats(logged_in_only=False))
chart_pc_total = sum(day.get("pc", 0) for day in data["pageviews_per_day_by_device"].values())
chart_chrome_total = sum(
day.get("Chrome", 0) for day in data["pageviews_per_day_by_browser"].values()
)
self.assertEqual(chart_pc_total, data["device_types"].get("pc", 0))
self.assertEqual(chart_chrome_total, data["browsers"].get("Chrome", 0))
def test_daily_stats_reflect_games_and_players(self):
before = run(stats.get_daily_stats())
base_total_players = before["total_players"]
@@ -126,7 +305,7 @@ class StatsCase(unittest.TestCase):
self.assertGreaterEqual(sum(data["rounds_per_day"].values()), 4)
def test_empty_referrer_excluded_from_top_referrers(self):
run(stats.record_pageview(path="/lobby", referrer="", user_agent=CHROME_UA))
run(stats.record_pageview(path="/history", referrer="", user_agent=CHROME_UA))
data = run(stats.get_daily_stats())
self.assertNotIn("", data["top_referrers"])