All files / web/src/lib useHeartbeat.ts

0% Statements 0/119
0% Branches 0/1
0% Functions 0/1
0% Lines 0/119

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120                                                                                                                                                                                                                                               
'use client'

import { useEffect, useRef, useCallback } from 'react'
import { usePathname } from 'next/navigation'

// Generate a unique session ID for this browser tab
function generateSessionId(): string {
  const timestamp = Date.now().toString(36)
  const random = Math.random().toString(36).substring(2, 10)
  return `${timestamp}-${random}`
}

// Heartbeat interval in milliseconds (30 seconds)
const HEARTBEAT_INTERVAL_MS = 30_000

/**
 * Hook that sends periodic heartbeats to track active sessions.
 *
 * Features:
 * - Only sends heartbeats when the tab is visible (Page Visibility API)
 * - Generates a unique session ID per tab
 * - Sends path changes for page view tracking
 * - Non-blocking: uses beacon API for reliability
 * - Minimal impact: tiny payload, infrequent requests
 */
export function useHeartbeat() {
  const sessionIdRef = useRef<string | null>(null)
  const pathname = usePathname()
  const lastPathRef = useRef<string | null>(null)
  const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)

  // Get or create session ID
  const getSessionId = useCallback(() => {
    if (!sessionIdRef.current) {
      // Try to get from sessionStorage for tab persistence
      const stored =
        typeof window !== 'undefined' ? sessionStorage.getItem('heartbeat_session_id') : null
      if (stored) {
        sessionIdRef.current = stored
      } else {
        sessionIdRef.current = generateSessionId()
        if (typeof window !== 'undefined') {
          sessionStorage.setItem('heartbeat_session_id', sessionIdRef.current)
        }
      }
    }
    return sessionIdRef.current
  }, [])

  // Send heartbeat
  const sendHeartbeat = useCallback(
    (path?: string) => {
      if (typeof window === 'undefined') return

      // Only send if tab is visible
      if (document.visibilityState !== 'visible') return

      const sessionId = getSessionId()
      const payload = JSON.stringify({
        sessionId,
        path: path || undefined,
      })

      // Use sendBeacon for reliability (doesn't block navigation)
      if (navigator.sendBeacon) {
        navigator.sendBeacon('/api/heartbeat', payload)
      } else {
        // Fallback to fetch (non-blocking)
        fetch('/api/heartbeat', {
          method: 'POST',
          body: payload,
          headers: { 'Content-Type': 'application/json' },
          keepalive: true,
        }).catch(() => {
          // Ignore errors - heartbeat is best-effort
        })
      }
    },
    [getSessionId]
  )

  // Setup heartbeat interval
  useEffect(() => {
    if (typeof window === 'undefined') return

    // Send initial heartbeat with path
    sendHeartbeat(pathname)
    lastPathRef.current = pathname

    // Setup interval
    intervalRef.current = setInterval(() => {
      sendHeartbeat()
    }, HEARTBEAT_INTERVAL_MS)

    // Handle visibility change - send heartbeat when tab becomes visible
    const handleVisibilityChange = () => {
      if (document.visibilityState === 'visible') {
        sendHeartbeat()
      }
    }
    document.addEventListener('visibilitychange', handleVisibilityChange)

    // Cleanup
    return () => {
      if (intervalRef.current) {
        clearInterval(intervalRef.current)
      }
      document.removeEventListener('visibilitychange', handleVisibilityChange)
    }
  }, [sendHeartbeat, pathname])

  // Send heartbeat on path change
  useEffect(() => {
    if (pathname && pathname !== lastPathRef.current) {
      sendHeartbeat(pathname)
      lastPathRef.current = pathname
    }
  }, [pathname, sendHeartbeat])
}