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:
+11
-6
@@ -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 (
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user