diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7522a36..2480acb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom'; -import { Suspense, lazy } from 'react'; +import { Suspense, lazy, useEffect } from 'react'; import { AuthProvider } from './hooks/useAuth'; import { ErrorBoundary } from './components/ErrorBoundary'; import { Navbar } from './components/Navbar/Navbar'; @@ -7,12 +7,14 @@ import { Footer } from './components/Footer/Footer'; import { ProtectedRoute } from './components/ProtectedRoute'; import { LandingPage } from './pages/LandingPage'; import { APPS } from './config/apps'; +import { startConnectionTracking, stopConnectionTracking } from './services/connectionTracker'; import './globals.css'; // Lazy load admin and login pages const AdminPage = lazy(() => import('./pages/AdminPage').then(module => ({ default: module.AdminPage }))); const VideosAdminPage = lazy(() => import('./pages/VideosAdminPage').then(module => ({ default: module.VideosAdminPage }))); const SpeechSoundsAdminPage = lazy(() => import('./pages/SpeechSoundsAdminPage').then(module => ({ default: module.SpeechSoundsAdminPage }))); +const StatsAdminPage = lazy(() => import('./pages/StatsAdminPage').then(module => ({ default: module.StatsAdminPage }))); const LoginPage = lazy(() => import('./pages/LoginPage').then(module => ({ default: module.LoginPage }))); // Loading fallback component @@ -26,6 +28,16 @@ const PageLoader = () => ( ); function App() { + // Start connection tracking when app loads + useEffect(() => { + startConnectionTracking(); + + // Stop tracking when app unmounts + return () => { + stopConnectionTracking(); + }; + }, []); + return ( @@ -74,6 +86,14 @@ function App() { } /> + + + + } + /> diff --git a/frontend/src/services/connectionTracker.ts b/frontend/src/services/connectionTracker.ts new file mode 100644 index 0000000..abf46dd --- /dev/null +++ b/frontend/src/services/connectionTracker.ts @@ -0,0 +1,132 @@ +/** + * Connection Tracker Service + * Sends periodic heartbeats to the server to indicate active connection + */ + +const HEARTBEAT_INTERVAL = 30 * 1000; // Send heartbeat every 30 seconds +const SESSION_ID_KEY = 'connection_session_id'; +let heartbeatInterval: NodeJS.Timeout | null = null; +let isTracking = false; +let visibilityCleanup: (() => void) | null = null; +let currentVideo: { title: string; channelName: string } | null = null; + +/** + * Get or create a unique session ID for this browser window/tab + * Uses localStorage which is isolated per incognito window + */ +function getSessionId(): string { + try { + let sessionId = localStorage.getItem(SESSION_ID_KEY); + if (!sessionId) { + // Generate a unique session ID for this window/tab + sessionId = `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + localStorage.setItem(SESSION_ID_KEY, sessionId); + } + return sessionId; + } catch (error) { + // Fallback if localStorage is not available + return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + } +} + +/** + * Start sending heartbeats to the server + */ +export function startConnectionTracking(): void { + if (isTracking) { + return; // Already tracking + } + + isTracking = true; + + // Send initial heartbeat immediately + sendHeartbeat(); + + // Then send periodic heartbeats + heartbeatInterval = setInterval(() => { + // Only send heartbeat if page is visible (not backgrounded on mobile) + if (!document.hidden) { + sendHeartbeat(); + } + }, HEARTBEAT_INTERVAL); + + // Handle page visibility changes (e.g., iPad locks, tab switches) + // When page becomes visible again, send immediate heartbeat + const handleVisibilityChange = () => { + if (!document.hidden && isTracking) { + // Page became visible - send heartbeat immediately + sendHeartbeat(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Store cleanup function + visibilityCleanup = () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; +} + +/** + * Stop sending heartbeats + */ +export function stopConnectionTracking(): void { + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + + // Clean up visibility change listener + if (visibilityCleanup) { + visibilityCleanup(); + visibilityCleanup = null; + } + + isTracking = false; +} + +/** + * Get the current route/pathname + */ +function getCurrentRoute(): string { + // Use window.location.pathname to get current route + // This works outside React Router context + return window.location.pathname || '/'; +} + +/** + * Set the current video being watched (call when video player opens) + */ +export function setCurrentVideo(video: { title: string; channelName: string } | null): void { + currentVideo = video; + // Send immediate heartbeat when video changes + if (isTracking) { + sendHeartbeat(); + } +} + +/** + * Send a heartbeat to the server with the session ID, current route, video info, and time limit usage + */ +async function sendHeartbeat(): Promise { + try { + const { settingsApi } = await import('./apiClient'); + const { getTimeUsedToday, getDailyLimitSync } = await import('./timeLimitService'); + + const sessionId = getSessionId(); + const route = getCurrentRoute(); + const timeUsed = getTimeUsedToday(); + const dailyLimit = getDailyLimitSync(); + + await settingsApi.heartbeat( + sessionId, + route, + currentVideo ? { title: currentVideo.title, channelName: currentVideo.channelName } : undefined, + { timeUsed, dailyLimit } + ); + } catch (error) { + // Silently fail - don't spam console with errors + // Connection tracking is not critical for app functionality + console.debug('Heartbeat failed:', error); + } +}