Browse Source

Add frontend connection tracking with heartbeat

drawing-pad
Stephanie Gredell 1 month ago
parent
commit
38aac1366c
  1. 22
      frontend/src/App.tsx
  2. 132
      frontend/src/services/connectionTracker.ts

22
frontend/src/App.tsx

@ -1,5 +1,5 @@ @@ -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'; @@ -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 = () => ( @@ -26,6 +28,16 @@ const PageLoader = () => (
);
function App() {
// Start connection tracking when app loads
useEffect(() => {
startConnectionTracking();
// Stop tracking when app unmounts
return () => {
stopConnectionTracking();
};
}, []);
return (
<ErrorBoundary>
<BrowserRouter>
@ -74,6 +86,14 @@ function App() { @@ -74,6 +86,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/admin/stats"
element={
<ProtectedRoute>
<StatsAdminPage />
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
</main>

132
frontend/src/services/connectionTracker.ts

@ -0,0 +1,132 @@ @@ -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<void> {
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);
}
}
Loading…
Cancel
Save