diff --git a/TASKS.md b/TASKS.md
new file mode 100644
index 0000000..8db5b4a
--- /dev/null
+++ b/TASKS.md
@@ -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é
+
+
+
+## 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.
+
+
diff --git a/api/__init__.py b/api/__init__.py
index 48934f9..a6e2662 100644
--- a/api/__init__.py
+++ b/api/__init__.py
@@ -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)
diff --git a/api/stats.py b/api/stats.py
index eae2a07..e3ff3c9 100644
--- a/api/stats.py
+++ b/api/stats.py
@@ -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/, /game/... (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},
}
diff --git a/db/models.py b/db/models.py
index 47370fd..3867512 100644
--- a/db/models.py
+++ b/db/models.py
@@ -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()
)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 265aaa6..5b3faaf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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}
)}
-
- } />
- } />
- } />
- } />
- } />
- }>
- } />
- } />
-
- } />
-
+ Načítava sa…
}>
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ }>
+ } />
+ } />
+
+ } />
+
+
>
);
}
diff --git a/frontend/src/components/GuessControls.tsx b/frontend/src/components/GuessControls.tsx
index bc7ed1e..ff4a607 100644
--- a/frontend/src/components/GuessControls.tsx
+++ b/frontend/src/components/GuessControls.tsx
@@ -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 (
diff --git a/frontend/src/lib/gameRules.ts b/frontend/src/lib/gameRules.ts
index a4ced8a..522bddf 100644
--- a/frontend/src/lib/gameRules.ts
+++ b/frontend/src/lib/gameRules.ts
@@ -12,3 +12,12 @@ export function computePlayable(hand: Hand, ledColor: CardColor | null): Set): number | null {
+ if (Object.keys(guesses).length !== 3) return null;
+ const alreadySum = Object.values(guesses).reduce((a, b) => a + b, 0);
+ return cardsInRound - alreadySum;
+}
diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts
index 51bbb8d..641eb66 100644
--- a/frontend/src/lib/socket.ts
+++ b/frontend/src/lib/socket.ts
@@ -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}`);
});
}
diff --git a/frontend/src/lib/track.ts b/frontend/src/lib/track.ts
new file mode 100644
index 0000000..469a643
--- /dev/null
+++ b/frontend/src/lib/track.ts
@@ -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(() => {});
+}
diff --git a/frontend/src/lib/useIsDesktop.ts b/frontend/src/lib/useIsDesktop.ts
index d67f0bd..c561ed4 100644
--- a/frontend/src/lib/useIsDesktop.ts
+++ b/frontend/src/lib/useIsDesktop.ts
@@ -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);
}, []);
diff --git a/frontend/src/pages/Auth.tsx b/frontend/src/pages/Auth.tsx
index 2763b8f..e830ba8 100644
--- a/frontend/src/pages/Auth.tsx
+++ b/frontend/src/pages/Auth.tsx
@@ -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 (
Bridžik
@@ -162,7 +168,7 @@ export default function Auth() {