All files / app/features/analytics hooks.ts

93.75% Statements 60/64
86.95% Branches 20/23
100% Functions 12/12
96.66% Lines 58/60

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 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 1412x   2x         34x 34x           11x                         6x       6x 6x 6x   6x 5x     6x   7x 5x 5x 5x                 6x                       15x         15x 15x 15x 15x 15x 15x 15x 15x 15x     15x 13x 13x 13x 1x 1x         15x     12x 12x         12x   11x 5x 5x 5x 5x     11x   11x 11x 11x 8x 3x 3x   5x 5x 2x 2x     3x 2x 2x             11x         15x    
import { useCallback, useEffect, useRef } from "react";
 
import { logEvent } from "./eventCollector";
 
/**
 * Returns a stable `logEvent` function for use in components.
 */
export function useLogEvent() {
  return useCallback(
    (
      eventType: string,
      properties?: Record<string, unknown>,
      value?: number,
    ) => {
      logEvent(eventType, properties, value);
    },
    [],
  );
}
 
/**
 * Returns [start, stop] callbacks for timing an event.
 * On `stop()`, logs the event with value = duration in seconds.
 *
 * `properties` is stored in a ref so callers don't need to memoize it.
 * `stop` accepts optional `extraProperties` for dynamic data at measurement time.
 */
export function useDurationEvent(
  eventType: string,
  properties: Record<string, unknown> = {},
) {
  const startTimeRef = useRef<number | null>(null);
  const propsRef = useRef(properties);
  propsRef.current = properties;
 
  const start = useCallback(() => {
    startTimeRef.current = performance.now();
  }, []);
 
  const stop = useCallback(
    (extraProperties?: Record<string, unknown>) => {
      if (startTimeRef.current === null) return;
      const durationS = (performance.now() - startTimeRef.current) / 1000;
      startTimeRef.current = null;
      logEvent(
        eventType,
        { ...propsRef.current, ...extraProperties },
        durationS,
      );
    },
    [eventType],
  );
 
  return [start, stop] as const;
}
 
/**
 * Returns a callback ref. Fires the event once via IntersectionObserver after
 * the element has been continuously >= `threshold` visible for `minDurationMs`
 * (default 0 = fires on first intersection).
 *
 * `properties`, `threshold`, and `minDurationMs` are stored in refs so callers
 * don't need to memoize them — the observer is only recreated when
 * `eventType` changes.
 */
export function useImpressionRef(
  eventType: string,
  properties: Record<string, unknown> = {},
  options?: { threshold?: number; minDurationMs?: number },
) {
  const observerRef = useRef<IntersectionObserver | null>(null);
  const visibleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  const hasFiredRef = useRef(false);
  const propsRef = useRef(properties);
  propsRef.current = properties;
  const thresholdRef = useRef(options?.threshold ?? 0.5);
  thresholdRef.current = options?.threshold ?? 0.5;
  const minDurationRef = useRef(options?.minDurationMs ?? 0);
  minDurationRef.current = options?.minDurationMs ?? 0;
 
  // Cleanup on unmount
  useEffect(() => {
    return () => {
      observerRef.current?.disconnect();
      if (visibleTimerRef.current) {
        clearTimeout(visibleTimerRef.current);
        visibleTimerRef.current = null;
      }
    };
  }, []);
 
  const callbackRef = useCallback(
    (node: HTMLElement | null) => {
      // Disconnect previous observer
      observerRef.current?.disconnect();
      Iif (visibleTimerRef.current) {
        clearTimeout(visibleTimerRef.current);
        visibleTimerRef.current = null;
      }
 
      if (!node || hasFiredRef.current) return;
 
      const fire = () => {
        Iif (hasFiredRef.current) return;
        hasFiredRef.current = true;
        logEvent(eventType, propsRef.current);
        observerRef.current?.disconnect();
      };
 
      observerRef.current = new IntersectionObserver(
        (entries) => {
          for (const entry of entries) {
            Iif (hasFiredRef.current) return;
            if (entry.isIntersecting) {
              if (minDurationRef.current === 0) {
                fire();
                return;
              }
              if (!visibleTimerRef.current) {
                visibleTimerRef.current = setTimeout(() => {
                  visibleTimerRef.current = null;
                  fire();
                }, minDurationRef.current);
              }
            } else if (visibleTimerRef.current) {
              clearTimeout(visibleTimerRef.current);
              visibleTimerRef.current = null;
            }
          }
        },
        { threshold: thresholdRef.current },
      );
 
      observerRef.current.observe(node);
    },
    [eventType],
  );
 
  return callbackRef;
}