Rozsirenie analytiky: login event, prepinac vsetci/prihlaseni, plny nazov krajiny

Pageview beacony uz neposielaju player_id z klienta (nedovereny vstup na
neautentifikovanom endpointe) -- prihlasenie sa eviduje server-side ako
event "login" (aj z registracie), s player_id skutocneho uctu. Admin
dashboard dostal prepinac scope vsetci/prihlaseni, ktory konzistentne
pocita grafy aj tabulky (kazdy login samostatne, navstevnicky den pre
anonymnu navstevnost). PageView.country teraz uklada cely anglicky nazov
krajiny namiesto ISO kodu (potrebna zmena schemy). Pridane sledovanie
klikov na "Pravidla hry" aj z GameList.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
tim
2026-07-04 12:43:37 +02:00
co-authored by Claude Sonnet 5
parent 7886b3a6b8
commit 28bb045274
10 changed files with 471 additions and 90 deletions
+11 -6
View File
@@ -2,6 +2,7 @@ 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';
@@ -73,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 (
+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(() => {});
}
+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
+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}