import React from 'react';
import type {
PromptImpactStatus,
PromptImpactWeekPoint,
} from '../../service/visibility/visibility.interface';
/** Plain-language explanation of how the per-prompt visibility score is built. */
export const VISIBILITY_SCORE_HELP =
'How strongly AI surfaces you for THIS exact query (0-100), scored on its ' +
'own - not a mention count, and not your overall reach. Weighted toward being ' +
'mentioned at all and your site being cited, so one cited appearance here ' +
'already scores high even while you are absent from other prompts. Your ' +
'headline visibility blends all prompts, so a few strong scores can still mean ' +
'low overall reach.';
/**
* Decide how to show a trend's change. A score that climbed from 0 is a first
* appearance, not a "+71 improvement", so it reads as "New" rather than a
* delta - keeps the numbers honest across the card, modal and article panel.
*
* @param {number | null} baselineScore - Earliest score in the window (or the
* pre-publish baseline); ``null`` when unknown.
* @param {number | null} currentScore - Latest score; ``null`` when no data.
* @param {number | null} deltaScore - Precomputed delta; ``null`` when absent.
* @returns {{kind: 'new'} | {kind: 'delta', value: number} | {kind: 'none'}}
* How to render the change.
*/
export function trendChange(
baselineScore: number | null,
currentScore: number | null,
deltaScore: number | null
): { kind: 'new' } | { kind: 'delta'; value: number } | { kind: 'none' } {
if (currentScore === null) return { kind: 'none' };
if ((baselineScore === null || baselineScore === 0) && currentScore > 0) {
return { kind: 'new' };
}
if (deltaScore !== null) return { kind: 'delta', value: deltaScore };
return { kind: 'none' };
}
/** Props for {@link TrendStatusIcon}. */
interface TrendStatusIconProps {
/** Trend bucket driving which glyph is drawn. */
status: PromptImpactStatus;
/** SVG edge length in px. */
size?: number;
}
/**
* Tiny inline status glyph (up / down / flat / collecting). Replaces the
* icon-library icons used in the reference so this repo keeps zero icon-pack
* dependencies; ``currentColor`` lets the parent tint it via the status class.
*
* @param {TrendStatusIconProps} props - Component props.
* @returns {JSX.Element} The status icon SVG.
*/
function TrendStatusIcon({
status,
size = 14,
}: TrendStatusIconProps): JSX.Element {
const common = {
width: size,
height: size,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
'aria-hidden': true,
};
if (status === 'improving') {
return (
);
}
if (status === 'declining') {
return (
);
}
if (status === 'collecting') {
return (
);
}
return (
);
}
/** Visual treatment per trend status: label and accent class. */
export const PROMPT_TREND_STATUS_META: Record<
PromptImpactStatus,
{ label: string; className: string }
> = {
improving: { label: 'improving', className: 'text-green-600' },
declining: { label: 'declining', className: 'text-red-600' },
flat: { label: 'flat', className: 'text-gray-500' },
collecting: { label: 'collecting data', className: 'text-gray-500' },
};
/** Props for {@link PromptSparkline}. */
interface PromptSparklineProps {
/** Weekly composite points, oldest first. */
series: PromptImpactWeekPoint[];
/** SVG width in px. */
width?: number;
/** SVG height in px. */
height?: number;
/** ISO week to dot on the line (e.g. an article's publish week). */
markerWeek?: string | null;
}
/**
* Tiny inline sparkline of composite scores (0-100), oldest to newest.
* Returns ``null`` for fewer than two points - a single dot reads as noise.
* Plots against the FIXED 0-100 scale (not the series' own min/max) so a
* barely-moving prompt reads as a near-straight line, and optionally dots a
* given week so a reader can see where publish sits on the trend.
*
* @param {PromptSparklineProps} props - Component props.
* @returns {JSX.Element | null} The sparkline SVG, or ``null``.
*/
export function PromptSparkline({
series,
width = 72,
height = 22,
markerWeek = null,
}: PromptSparklineProps): JSX.Element | null {
if (series.length < 2) return null;
const verticalPadding = 2;
const usableHeight = height - verticalPadding * 2;
const stepX = width / (series.length - 1);
const coords = series.map((point, index) => {
const score = Math.max(0, Math.min(100, point.composite_score));
return {
x: index * stepX,
y: verticalPadding + (1 - score / 100) * usableHeight,
week: point.week_iso,
};
});
const points = coords
.map(coord => `${coord.x.toFixed(1)},${coord.y.toFixed(1)}`)
.join(' ');
let marker: { x: number; y: number } | null = null;
if (markerWeek) {
const exact = coords.findIndex(coord => coord.week === markerWeek);
const index =
exact >= 0
? exact
: coords.findIndex(coord => (coord.week || '') >= markerWeek);
if (index >= 0) marker = { x: coords[index].x, y: coords[index].y };
}
return (
);
}
/** Props for {@link PromptTrendChip}. */
interface PromptTrendChipProps {
/** Latest composite score, or ``null`` when no data yet. */
currentScore: number | null;
/** Delta vs the baseline week, or ``null`` until two weeks exist. */
deltaScore: number | null;
/** Trend bucket driving icon + colour. */
status: PromptImpactStatus;
/** Weekly points for the inline sparkline. */
series: PromptImpactWeekPoint[];
}
/**
* Compact "Visibility 64 /100 ▲+18 ▁▂▅▇" trend chip for a prompt card. Shows a
* muted "collecting data" hint while a prompt has fewer than two scored weeks
* so the merchant knows it is tracked but not yet charted.
*
* @param {PromptTrendChipProps} props - Component props.
* @returns {JSX.Element} The chip element.
*/
export function PromptTrendChip({
currentScore,
deltaScore,
status,
series,
}: PromptTrendChipProps): JSX.Element {
const meta = PROMPT_TREND_STATUS_META[status];
if (currentScore === null) {
return (
collecting data
);
}
const baselineScore = series.length ? series[0].composite_score : null;
const change = trendChange(baselineScore, currentScore, deltaScore);
return (
Visibility
{currentScore}
/100
{change.kind === 'new' && (
New
)}
{change.kind === 'delta' && (
{`${change.value > 0 ? '+' : ''}${change.value}`}
)}
);
}
export { TrendStatusIcon };