import React, { useEffect, useMemo, useState } from 'react'; import InfoTooltip from '../agent-analytics/InfoTooltip'; import type { ArticlePromptImpactResponse, PromptImpactEntry, } from '../../service/visibility/visibility.interface'; import { getArticlePromptImpact } from '../../service/visibility/visibility.service'; import { PROMPT_TREND_STATUS_META, PromptSparkline, TrendStatusIcon, VISIBILITY_SCORE_HELP, trendChange, } from './PromptTrend'; import { toneToBadgeClass } from './helpers'; interface PromptImpactPanelProps { /** Recomaze client id. */ clientId: string; /** Recomaze JWT. */ token: string; /** Article whose prompt impact to load. */ articleId: string; } /** Aggregate read off the article's tracked prompts for the hero + verdict. */ interface ImpactSummary { trackedCount: number; avgScore: number | null; avgBaseline: number | null; avgDelta: number | null; improving: number; declining: number; collecting: number; } /** * Roll the per-prompt entries into the headline numbers and the verdict copy. * * @param {PromptImpactEntry[]} prompts - Per-prompt impact entries. * @returns {ImpactSummary} Aggregate counts and averages. */ function summarize(prompts: PromptImpactEntry[]): ImpactSummary { const tracked = prompts.filter( entry => entry.tracked && entry.current_score !== null ); const scored = tracked.map(entry => entry.current_score as number); const baselines = tracked .map(entry => entry.baseline_score) .filter((score): score is number => score !== null); const withDelta = tracked .map(entry => entry.delta_score) .filter((delta): delta is number => delta !== null); const avg = (values: number[]): number | null => values.length === 0 ? null : Math.round( values.reduce((sum, value) => sum + value, 0) / values.length ); return { trackedCount: tracked.length, avgScore: avg(scored), avgBaseline: avg(baselines), avgDelta: avg(withDelta), improving: tracked.filter(entry => entry.status === 'improving').length, declining: tracked.filter(entry => entry.status === 'declining').length, collecting: prompts.filter(entry => entry.status === 'collecting').length, }; } /** * Pick a friendly one-line verdict from the aggregate. * * @param {ImpactSummary} summary - Aggregate counts and averages. * @returns {string} A human verdict sentence. */ function verdictFor(summary: ImpactSummary): string { if (summary.trackedCount === 0 || summary.avgDelta === null) { return 'Too early to tell. Impact shows up here after the next weekly scan.'; } if (summary.improving > summary.declining && summary.avgDelta > 0) { return 'Paying off. Visibility is climbing on the prompts this article targets.'; } if (summary.declining > summary.improving && summary.avgDelta < 0) { return 'Visibility dipped since publish. The content may need a refresh.'; } return 'Mixed so far. Some prompts moved up, others slipped.'; } /** Inline right-arrow glyph for the before -> after readout. */ const ArrowRightIcon = ({ size = 12 }: { size?: number }): JSX.Element => ( ); /** * Render one targeted prompt's impact row with a before -> after readout. * * @param {object} props - Component props. * @param {PromptImpactEntry} props.entry - The per-prompt impact entry. * @param {string | null} props.publishedWeek - Week to dot on the sparkline. * @returns {JSX.Element} The row element. */ const PromptImpactRow = ({ entry, publishedWeek, }: { entry: PromptImpactEntry; publishedWeek: string | null; }): JSX.Element => { if (!entry.tracked) { return (

{entry.prompt_text}

Not in your tracked set yet. Add it on the prompts page to start measuring this one.

); } const meta = PROMPT_TREND_STATUS_META[entry.status]; const hasBeforeAfter = entry.baseline_score !== null && entry.baseline_score > 0 && entry.current_score !== null; const change = trendChange( entry.baseline_score, entry.current_score, entry.delta_score ); const tone = change.kind === 'delta' ? meta.className : 'text-gray-500'; return (

{entry.prompt_text}

{hasBeforeAfter ? ( {entry.baseline_score} {entry.current_score} since publish ) : ( {change.kind === 'new' ? `Now surfacing you at ${entry.current_score}/100 since publish` : entry.current_score !== null ? `Now ${entry.current_score}/100 · baseline lands after the next scan` : 'First scan after publish pending'} )}
{change.kind === 'new' ? ( New ) : change.kind === 'delta' ? ( <> {`${change.value > 0 ? '+' : ''}${change.value}`} ) : ( )}
); }; /** * Article "Prompt impact" panel (Beta). Leads with the average visibility * across the prompts this article targets and a plain-language verdict, then * breaks it down per prompt with a before -> after readout and a sparkline * dotted at the publish week. Correlation only - it marks publish, it does not * claim the article caused the change. Fetches client-side so it never blocks * the article render; stays silent when the article targets no prompts. * * @param {PromptImpactPanelProps} props - Component props. * @returns {JSX.Element | null} The panel, or ``null`` when there is nothing * to show. */ export const PromptImpactPanel = ({ clientId, token, articleId, }: PromptImpactPanelProps): JSX.Element | null => { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let cancelled = false; setLoading(true); setError(null); getArticlePromptImpact(clientId, token, articleId) .then(result => { if (!cancelled) setData(result); }) .catch(err => { if (!cancelled) { setError( err instanceof Error ? err.message : 'Failed to load impact.' ); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [clientId, token, articleId]); const summary = useMemo( () => (data ? summarize(data.prompts) : null), [data] ); // Nothing to show: no targeted prompts at all. Stay invisible rather than // render an empty card on manual articles with no prompt linkage. if (!loading && !error && (!data || data.prompts.length === 0)) { return null; } const heroChange = summary ? trendChange(summary.avgBaseline, summary.avgScore, summary.avgDelta) : ({ kind: 'none' } as const); return (

Prompt impact

Beta
{data?.published_week && ( {`Published ${data.published_week}`} )}
{loading && (
)} {error &&

{error}

} {!loading && !error && data && summary && ( <>
Avg visibility
{summary.avgScore ?? '—'} {heroChange.kind === 'new' && ( New )} {heroChange.kind === 'delta' && ( 0 ? 'text-green-600' : heroChange.value < 0 ? 'text-red-600' : 'text-gray-500' }`} > = 0 ? 'improving' : 'declining'} size={16} /> {`${heroChange.value > 0 ? '+' : ''}${heroChange.value}`} )}

{verdictFor(summary)}

{`Across ${summary.trackedCount} tracked prompt${ summary.trackedCount === 1 ? '' : 's' } this article targets, vs the week before publish. Correlation, not proof.`}

{data.prompts.map((entry, index) => ( ))}
)}
); }; export default PromptImpactPanel;