/** * Lazy proof layer for the wins/losses rows: the report already carries the * per-engine verdicts, but the actual AI quotes and cited sources would bloat * it, so they are fetched once per prompt the first time its row expands. */ import { useCallback, useRef, useState } from 'react'; import type { PromptResponseEntry } from '../../service/visibility/visibility.interface'; import { getPromptResponses } from '../../service/visibility/visibility.service'; import { toIsoWeek } from './helpers'; /** One prompt's proof-fetch lifecycle. */ export type PromptProof = | { status: 'loading' } | { status: 'failed' } | { status: 'ready'; entries: PromptResponseEntry[] }; /** What {@link usePromptProof} hands back to a caller. */ interface PromptProofLayer { /** The prompt's current fetch state, or ``null`` when never requested. */ proofFor: (promptId: string) => PromptProof | null; /** Idempotent loader - safe to call on every expand. */ loadProof: (promptId: string) => void; } /** * Latest scan entry per engine. The responses endpoint returns the full * history; the inline proof wants each engine's newest word only. * * @param {PromptResponseEntry[]} entries - Full history, any order. * @returns {PromptResponseEntry[]} One entry per engine, newest first. */ const latestEntryPerEngine = ( entries: PromptResponseEntry[] ): PromptResponseEntry[] => { const byEngine = new Map(); for (const entry of entries) { const existing = byEngine.get(entry.engine); if (!existing || (entry.scan_date ?? '') > (existing.scan_date ?? '')) { byEngine.set(entry.engine, entry); } } return [...byEngine.values()].sort((first, second) => (second.scan_date ?? '').localeCompare(first.scan_date ?? '') ); }; /** * The proof entries to show under a row of the given report week: entries * scanned IN that week when any exist (reduced to one per engine), so a * historic report's proof agrees with its verdict chips; each engine's * latest entry otherwise - the cards carry their scan dates, so the time * basis stays visible either way. Week-filter first, THEN reduce: reducing * first would always pick the newest scan and never match an old week. * * @param {PromptResponseEntry[]} entries - Full response history. * @param {string | null} weekIso - The report's ISO week, or ``null`` for latest. * @returns {PromptResponseEntry[]} One entry per engine. */ export const proofEntriesForWeek = ( entries: PromptResponseEntry[], weekIso: string | null ): PromptResponseEntry[] => { const inWeek = weekIso ? entries.filter( entry => entry.scan_date && toIsoWeek(new Date(entry.scan_date)) === weekIso ) : []; return latestEntryPerEngine(inWeek.length > 0 ? inWeek : entries); }; /** * Cache-and-fetch for per-prompt proof entries. * * @param {string} clientId - Merchant client id. * @param {string} token - Merchant JWT. * @param {string} brandId - Brand identifier. * @returns {PromptProofLayer} Lookup plus idempotent loader. */ export const usePromptProof = ( clientId: string, token: string, brandId: string ): PromptProofLayer => { const [proofByPromptId, setProofByPromptId] = useState< Record >({}); // Ref-guarded so a double click can't race two fetches for one prompt. const inFlight = useRef>(new Set()); const loadProof = useCallback( (promptId: string) => { if (inFlight.current.has(promptId)) return; inFlight.current.add(promptId); setProofByPromptId(previous => previous[promptId]?.status === 'ready' ? previous : { ...previous, [promptId]: { status: 'loading' } } ); getPromptResponses(clientId, token, brandId, promptId) .then(response => { // Full history is kept: the week the caller renders for decides // the reduction (see proofEntriesForWeek). setProofByPromptId(previous => ({ ...previous, [promptId]: { status: 'ready', entries: response.entries }, })); }) .catch(() => { inFlight.current.delete(promptId); setProofByPromptId(previous => ({ ...previous, [promptId]: { status: 'failed' }, })); }); }, [clientId, token, brandId] ); return { proofFor: (promptId: string) => proofByPromptId[promptId] ?? null, loadProof, }; };