All files / src/hooks usePresentationData.ts

94% Statements 47/50
83.72% Branches 36/43
100% Functions 7/7
100% Lines 46/46

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 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186                                                                                                    1x     1x 1x     36x 36x 36x             36x 17x   17x 17x   17x 18x 18x 18x 18x 18x 18x 18x 18x 1x   18x         18x   18x     18x         17x   17x 17x 17x           36x   22x             36x             36x   19x         36x             36x               36x               36x     36x 36x   36x 1x 1x 1x 1x 1x     36x                                  
/**
 * usePresentationData Hook
 * Story 10.8a: Moderator Presentation Page — Functional
 *
 * Loads all data for the moderator presentation page with 5 queries:
 *   1. Main event (topics, venue, sessions, speakers) — polled every 60 s
 *   2. Public organizers (Committee slide)
 *   3. Upcoming events (Upcoming Events slide)
 *   4. Presentation settings (About slide)
 *   5. Global teaser images (shown on all events)
 *
 * Sessions and speakers are embedded in query 1 — no separate sessions call needed.
 *
 * ACs: #1, #37, #38, #42
 */
 
import { useEffect, useRef } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { liveTimingService } from '@/services/liveTimingService';
import {
  getPresentationData,
  getPublicOrganizers,
  getUpcomingEvents,
  getPresentationSettings,
  getGlobalTeaserImages,
  type PresentationEventDetail,
  type PresentationSession,
  type PresentationSettings,
} from '@/services/presentationService';
import type { User } from '@/types/user.types';
import type { components } from '@/types/generated/events-core-api.types';
 
export interface PresentationData {
  event: PresentationEventDetail | null;
  sessions: PresentationSession[];
  organizers: User[];
  upcomingEvents: components['schemas']['Event'][];
  settings: PresentationSettings | null;
  globalTeaserImages: components['schemas']['TeaserImageItem'][];
}
 
export interface UsePresentationDataResult {
  data: PresentationData;
  isLoading: boolean;
  /** True only when the *initial* load of all sources has failed */
  isInitialLoadError: boolean;
  refetch: () => void;
}
 
const DEFAULT_ABOUT_TEXT =
  'BATbern ist eine unabhängige Plattform, die Berner Architekten und Ingenieure vernetzt.';
 
/** Adaptive live-timing poll cadence (ms). */
const LIVE_TIMING_POLL_ACTIVE = 5000;
const LIVE_TIMING_POLL_HIDDEN = 30000;
 
export function usePresentationData(eventCode: string): UsePresentationDataResult {
  const queryClient = useQueryClient();
  const etagRef = useRef<string | null>(null);
  const lastVersionRef = useRef<number | null>(null);
 
  // Story 15.1: REST polling replaces the STOMP STATE_UPDATE subscription. Poll the cheap
  // live-timing endpoint anonymously with If-None-Match (304 when unchanged); when the
  // monotonic version advances (an organizer ended/extended/delayed a session), invalidate
  // the event query so the presenter reflects new session times — exactly what the WS
  // cache-invalidation did, without per-task in-memory broker state.
  useEffect(() => {
    Iif (!eventCode) return;
 
    let cancelled = false;
    let timeoutId: ReturnType<typeof setTimeout> | null = null;
 
    const poll = async () => {
      Iif (cancelled) return;
      try {
        const result = await liveTimingService.getLiveTiming(eventCode, etagRef.current, true);
        Iif (cancelled) return;
        etagRef.current = result.etag;
        Eif (result.status === 200 && result.data) {
          const version = result.data.version;
          if (lastVersionRef.current !== null && version !== lastVersionRef.current) {
            void queryClient.invalidateQueries({ queryKey: ['presentation-event', eventCode] });
          }
          lastVersionRef.current = version;
        }
      } catch {
        // Transient — next tick retries; the 60s event poll is the safety net.
      } finally {
        Eif (!cancelled) {
          const interval =
            typeof document !== 'undefined' && document.hidden
              ? LIVE_TIMING_POLL_HIDDEN
              : LIVE_TIMING_POLL_ACTIVE;
          timeoutId = setTimeout(poll, interval);
        }
      }
    };
 
    void poll();
 
    return () => {
      cancelled = true;
      Eif (timeoutId) clearTimeout(timeoutId);
    };
  }, [eventCode, queryClient]);
 
  // Single event call — includes topics, venue, sessions and speakers.
  // Polled every 60 s as a safety net; the live-timing poll above drives prompt refresh.
  const eventQuery = useQuery({
    queryKey: ['presentation-event', eventCode],
    queryFn: () => getPresentationData(eventCode),
    refetchInterval: 60_000,
    refetchIntervalInBackground: true,
    retry: 1,
    staleTime: 30_000,
  });
 
  const organizersQuery = useQuery({
    queryKey: ['presentation-organizers'],
    queryFn: getPublicOrganizers,
    staleTime: 10 * 60 * 1000,
    retry: 1,
  });
 
  const upcomingQuery = useQuery({
    queryKey: ['presentation-upcoming-events', eventCode],
    queryFn: () => getUpcomingEvents(eventCode),
    staleTime: 5 * 60 * 1000,
    retry: 1,
  });
 
  const settingsQuery = useQuery({
    queryKey: ['presentation-settings'],
    queryFn: getPresentationSettings,
    staleTime: 5 * 60 * 1000,
    retry: 1,
  });
 
  const globalTeaserImagesQuery = useQuery({
    queryKey: ['presentation-global-teaser-images'],
    queryFn: getGlobalTeaserImages,
    staleTime: 5 * 60 * 1000,
    retry: 1,
  });
 
  const isLoading =
    eventQuery.isLoading ||
    organizersQuery.isLoading ||
    upcomingQuery.isLoading ||
    settingsQuery.isLoading ||
    globalTeaserImagesQuery.isLoading;
 
  // AC #42: surface error only when initial load (event) fails
  const isInitialLoadError =
    !isLoading && (eventQuery.isError || settingsQuery.isError) && eventQuery.data == null;
 
  // Sessions are embedded in the event response — guard against paginated shape
  const rawSessions = eventQuery.data?.sessions;
  const sessions: PresentationSession[] = Array.isArray(rawSessions) ? rawSessions : [];
 
  const refetch = () => {
    void eventQuery.refetch();
    void organizersQuery.refetch();
    void upcomingQuery.refetch();
    void settingsQuery.refetch();
    void globalTeaserImagesQuery.refetch();
  };
 
  return {
    data: {
      event: eventQuery.data ?? null,
      sessions,
      organizers: organizersQuery.data ?? [],
      upcomingEvents: upcomingQuery.data ?? [],
      settings: settingsQuery.data ?? {
        aboutText: DEFAULT_ABOUT_TEXT,
        partnerCount: 9,
      },
      globalTeaserImages: globalTeaserImagesQuery.data ?? [],
    },
    isLoading,
    isInitialLoadError,
    refetch,
  };
}