import React, { useMemo } from 'react'; import type { ChartOptions, TooltipItem } from 'chart.js'; import ChartCanvas from '../agent-analytics/ChartCanvas'; import EmptyState from '../agent-analytics/EmptyState'; import InfoTooltip from '../agent-analytics/InfoTooltip'; import type { ScanHistoryResponse } from '../../service/visibility/visibility.interface'; import { toneToBadgeClass, trendToMeta } from './helpers'; interface ScanHistoryChartProps { scanHistory: ScanHistoryResponse | null; loading: boolean; } /** * Dual-axis line chart showing the weekly Visibility score (left axis) * and Share of Voice (right axis) over the backend's retained history. * Falls back to grouped bars for a single week. */ const ScanHistoryChart = ({ scanHistory, loading, }: ScanHistoryChartProps): JSX.Element => { const history = useMemo( () => scanHistory?.history ?? [], [scanHistory?.history] ); const sorted = useMemo( () => [...history].sort((a, b) => { const av = a.week_iso || a.date || ''; const bv = b.week_iso || b.date || ''; return av < bv ? -1 : av > bv ? 1 : 0; }), [history] ); const isSinglePoint = sorted.length <= 1; const chartData = useMemo(() => { if (isSinglePoint) { return { labels: sorted.map(point => point.week_iso || point.date || ''), datasets: [ { label: 'Visibility score', data: sorted.map(point => point.visibility_score ?? 0), backgroundColor: 'rgba(183, 0, 124, 0.85)', borderColor: '#B7007C', borderWidth: 1, borderRadius: 8, yAxisID: 'y', barPercentage: 0.4, categoryPercentage: 0.6, }, { label: 'Share of voice', data: sorted.map(point => point.share_of_voice ?? 0), backgroundColor: 'rgba(44, 110, 203, 0.85)', borderColor: '#2c6ecb', borderWidth: 1, borderRadius: 8, yAxisID: 'y1', barPercentage: 0.4, categoryPercentage: 0.6, }, ], }; } return { labels: sorted.map(point => point.week_iso || point.date || ''), datasets: [ { label: 'Visibility score', data: sorted.map(point => point.visibility_score ?? 0), borderColor: '#B7007C', backgroundColor: 'rgba(183, 0, 124, 0.12)', pointBackgroundColor: '#B7007C', pointBorderColor: '#fff', pointBorderWidth: 2, borderWidth: 2, tension: 0.3, pointRadius: 4, pointHoverRadius: 6, yAxisID: 'y', fill: true, }, { label: 'Share of voice', data: sorted.map(point => point.share_of_voice ?? 0), borderColor: '#2c6ecb', backgroundColor: 'rgba(44, 110, 203, 0.12)', pointBackgroundColor: '#2c6ecb', pointBorderColor: '#fff', pointBorderWidth: 2, borderWidth: 2, borderDash: [6, 4], tension: 0.3, pointRadius: 4, pointHoverRadius: 6, yAxisID: 'y1', fill: false, }, ], }; }, [sorted, isSinglePoint]); const tooltipLabel = ( item: TooltipItem<'line'> | TooltipItem<'bar'> ): string => { const v = item.parsed.y; const unit = item.dataset.yAxisID === 'y1' ? '%' : '/100'; return `${item.dataset.label ?? ''}: ${v}${unit}`; }; const lineOptions: ChartOptions<'line'> = useMemo( () => ({ responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false, }, plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: tooltipLabel }, }, }, scales: { y: { type: 'linear', position: 'left', suggestedMin: 0, suggestedMax: 100, title: { display: true, text: 'Visibility score' }, }, y1: { type: 'linear', position: 'right', suggestedMin: 0, suggestedMax: 100, grid: { drawOnChartArea: false }, title: { display: true, text: 'Share of voice (%)' }, }, }, }), [] ); const barOptions: ChartOptions<'bar'> = useMemo( () => ({ responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' }, tooltip: { callbacks: { label: tooltipLabel }, }, }, scales: { y: { type: 'linear', position: 'left', suggestedMin: 0, suggestedMax: 100, title: { display: true, text: 'Visibility score' }, }, y1: { type: 'linear', position: 'right', suggestedMin: 0, suggestedMax: 100, grid: { drawOnChartArea: false }, title: { display: true, text: 'Share of voice (%)' }, }, }, }), [] ); const trend = trendToMeta(scanHistory?.trend ?? 'stable'); const trendPct = typeof scanHistory?.trend_percentage === 'number' ? scanHistory.trend_percentage : null; return (