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 };