diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5b3faaf..ef9ec3e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -76,6 +76,8 @@ function AppInner() { // too -- that's the dashboard viewing its own traffic, not player usage. // Skip /, /lobby, /game -- high-frequency in-game navigation with no // analytical value; the backend drops these anyway (api/stats.py _SKIPPED_PATHS). + // The visitor's first touch (incl. landings on "/", which both skips would + // otherwise swallow) is captured by trackLanding() in main.tsx instead. useEffect(() => { if (navigationType === 'REPLACE') return; if (location.pathname.startsWith('/admin')) return; diff --git a/frontend/src/lib/track.ts b/frontend/src/lib/track.ts index 469a643..9bbab9a 100644 --- a/frontend/src/lib/track.ts +++ b/frontend/src/lib/track.ts @@ -10,3 +10,14 @@ export function trackEvent(path: string, referrer = '') { keepalive: true, }).catch(() => {}); } + +/** One "landing" event per full page load — the visitor's first touch, + * carrying document.referrer (e.g. facebook.com). Without it a visit that + * starts on "/" leaves no trace at all: "/" is in the beacon's skip list and + * the auth-gate redirect to /auth is a REPLACE navigation (see App.tsx), so + * both hops are skipped. Not sent on /admin — the dashboard doesn't track + * itself. */ +export function trackLanding() { + if (window.location.pathname.startsWith('/admin')) return; + trackEvent('landing', document.referrer); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a002937..a925069 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -3,11 +3,15 @@ import ReactDOM from 'react-dom/client'; import App from './App'; import './index.css'; import { setupSocketListeners, socket, setAuthToken } from './lib/socket'; +import { trackLanding } from './lib/track'; setupSocketListeners(); // Carry the stored session token into the handshake so the server auto-logs us in. setAuthToken(localStorage.getItem('bridzik_token')); socket.connect(); +// Here (not in a component effect): runs exactly once per full page load, +// unaffected by StrictMode double-mounting. +trackLanding(); ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/tests/test_stats.py b/tests/test_stats.py index 7016012..fa25af8 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -159,6 +159,20 @@ class StatsCase(unittest.TestCase): data = run(stats.get_daily_stats()) self.assertGreaterEqual(data["top_paths"].get("login", 0), 1) + def test_landing_event_preserves_referrer(self): + # "landing" event (main.tsx, jeden na kazdy plny load stranky) nesie + # referrer prveho dotyku -- jediny zaznam z navstevy, ktora zacina na + # "/" (preklik z FB a pod.), kedze "/" aj REPLACE redirecty sa skipuju. + run(stats.record_pageview( + path="landing", + referrer="https://facebook.com/", + user_agent=CHROME_UA, + ip="203.0.113.99", + )) + data = run(stats.get_daily_stats()) + self.assertGreaterEqual(data["top_paths"].get("landing", 0), 1) + self.assertGreaterEqual(data["top_referrers"].get("https://facebook.com/", 0), 1) + def test_rules_view_event_recorded_without_player_id(self): run(stats.record_pageview(path="rules_view", referrer="", user_agent=CHROME_UA)) data = run(stats.get_daily_stats())