import { useState, useEffect, useCallback, useMemo } from 'react'; import { __ } from '../lib/i18n'; import { Button } from '@/components/ui/button'; import { LoadingSpinner } from './screen-loader'; import { ResponsiveContainer, ComposedChart, CartesianGrid, Line, Area, XAxis, YAxis, Tooltip, } from 'recharts'; type TimeRange = '7d' | '30d' | '90d'; interface TimelinePoint { date: string; score: number | null; } interface TimelineData { range: string; interval: string; current_score: number | null; previous_score: number | null; delta: number | null; delta_percent: number | null; trend: 'up' | 'down' | 'flat' | 'na'; points: TimelinePoint[]; } interface VisibilityTimelineProps { apiRoot: string; apiNamespace: string; } // ─── Chart data shape ──────────────────────────────────────────────────────── interface ChartPoint { date: string; score: number | null; /** Set on the junction point + every projected day after the last real data */ projectedScore: number | null; chartIsForecast: boolean; } function dateToStr(d: Date): string { return [ d.getFullYear(), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0'), ].join('-'); } function rangeTodays(range: TimeRange): number { return range === '7d' ? 7 : range === '30d' ? 30 : 90; } /** * Build the full date range for the chart. * * Rules: * - Every day in [today - (rangeDays-1) … today] appears on the X-axis. * - Days with a real API score → solid line, real dot. * - Past days with no record → carry-forward last known score, solid line. * (They happened; we just didn't recalculate that day.) * - Today, only if it has no record yet → single dashed projected point + hollow dot. * The previous solid point is the junction (gets projectedScore = its own score). */ function buildChartData(points: TimelinePoint[], rangeDays: number): ChartPoint[] { const realMap = new Map(); for (const p of points) { if (p.score !== null) realMap.set(p.date, p.score); } if (realMap.size === 0) return []; const today = new Date(); const todayISO = dateToStr(today); const hasToday = realMap.has(todayISO); // Generate every date in the range const allDates: string[] = []; for (let i = rangeDays - 1; i >= 0; i--) { const d = new Date(today); d.setDate(d.getDate() - i); allDates.push(dateToStr(d)); } const result: ChartPoint[] = []; let lastKnownScore: number | null = null; for (const date of allDates) { const realScore = realMap.get(date) ?? null; if (realScore !== null) lastKnownScore = realScore; if (date === todayISO) { if (hasToday) { // Today has a real record → solid result.push({ date, score: realScore, projectedScore: null, chartIsForecast: false }); } else if (lastKnownScore !== null) { // Today has no record → projected (dashed). Mark previous point as junction. if (result.length > 0) { const prev = result[result.length - 1]; result[result.length - 1] = { ...prev, projectedScore: prev.score }; } result.push({ date, score: null, projectedScore: lastKnownScore, chartIsForecast: true }); } } else { // Past day: use real score if available, otherwise carry forward last known (solid) const scoreToUse = realScore ?? lastKnownScore; if (scoreToUse !== null) { result.push({ date, score: scoreToUse, projectedScore: null, chartIsForecast: false }); } // Skip days before the first data point (no score to carry forward yet) } } return result; } // ─── Tooltip ──────────────────────────────────────────────────────────────── interface TooltipPayloadItem { value: number; dataKey: string; } interface CustomTooltipProps { active?: boolean; payload?: TooltipPayloadItem[]; label?: string; } function fmtDate(dateStr: string) { return new Date(dateStr + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); } function CustomTooltip({ active, payload, label }: CustomTooltipProps) { if (!active || !payload?.length || !label) return null; const realScore = payload.find((p) => p.dataKey === 'score')?.value; const projScore = payload.find((p) => p.dataKey === 'projectedScore')?.value; const score = realScore ?? projScore; if (score == null) return null; const chartIsForecast = realScore == null && projScore != null; return (
{fmtDate(label)} {chartIsForecast && ( ({__('projected', 'llmagnet-llm-txt-generator')}) )}
{score.toFixed(1)}
); } // ─── Dots ──────────────────────────────────────────────────────────────────── interface DotBaseProps { cx?: number; cy?: number; index?: number; } interface RealDotProps extends DotBaseProps { activeIndex: number | null; setHoverIndex: (i: number | null) => void; pinnedIndex: number | null; togglePin: (i: number) => void; chartData: ChartPoint[]; } /** Solid interactive dot — only for real (non-projected) points on the score line */ function RealDot({ cx, cy, index, activeIndex, setHoverIndex, pinnedIndex, togglePin, chartData }: RealDotProps) { if (cx == null || cy == null || index == null) return null; // Don't render a dot at the projected-today slot (score is null there) if (chartData[index]?.score == null) return null; const isActive = index === activeIndex; return ( { if (pinnedIndex == null) setHoverIndex(index!); }} onMouseLeave={() => { if (pinnedIndex == null) setHoverIndex(null); }} onClick={() => togglePin(index!)} /> ); } interface ProjectedDotProps extends DotBaseProps { chartData: ChartPoint[]; } /** Hollow dashed dot — only for the today projected point */ function ProjectedDot({ cx, cy, index, chartData }: ProjectedDotProps) { if (cx == null || cy == null || index == null) return null; // Only render on the actual projected (today) point, not the junction if (!chartData[index]?.chartIsForecast) return null; return ( ); } // ─── Hover bar cursor ──────────────────────────────────────────────────────── interface HoverBarCursorProps { viewBox?: { x?: number; y?: number; width?: number; height?: number }; points?: Array<{ x: number; y: number }>; width?: number; height?: number; } function HoverBarCursor(props: HoverBarCursorProps) { const DOT_R = 10, DOT_STROKE = 5, BAR_W = 44; const vb = props?.viewBox; const pts = Array.isArray(props?.points) ? props.points : []; const p = pts.length ? pts.reduce((acc, cur) => (cur?.y > (acc?.y ?? -Infinity) ? cur : acc), pts[0]) : null; const x0 = typeof vb?.x === 'number' ? vb.x : 0; const y0 = typeof vb?.y === 'number' ? vb.y : 0; const w = typeof vb?.width === 'number' ? vb.width : props?.width; const h = typeof vb?.height === 'number' ? vb.height : props?.height; if (!p || typeof p.x !== 'number' || typeof p.y !== 'number' || typeof h !== 'number') return null; const bottomY = y0 + h; const startY = Math.min(bottomY, p.y + DOT_R + DOT_STROKE / 2); const barH = Math.max(0, bottomY - startY); if (barH <= 1) return null; const rawX = p.x - BAR_W / 2; const maxX = typeof w === 'number' ? x0 + w - BAR_W : rawX; const barX = typeof w === 'number' ? Math.max(x0, Math.min(rawX, maxX)) : rawX; const r = Math.min(18, BAR_W / 2, barH / 2); const xL = barX, xR = barX + BAR_W, yT = startY, yB = startY + barH; const d = [ `M ${xL} ${yT}`, `L ${xR} ${yT}`, `L ${xR} ${yB - r}`, `Q ${xR} ${yB} ${xR - r} ${yB}`, `L ${xL + r} ${yB}`, `Q ${xL} ${yB} ${xL} ${yB - r}`, 'Z', ].join(' '); return ( ); } // ─── Range selector ────────────────────────────────────────────────────────── const RANGES: { label: string; value: TimeRange }[] = [ { label: '7d', value: '7d' }, { label: '30d', value: '30d' }, { label: '90d', value: '90d' }, ]; // ─── Main component ────────────────────────────────────────────────────────── export function VisibilityTimeline({ apiRoot, apiNamespace }: VisibilityTimelineProps) { const [range, setRange] = useState('7d'); const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hoverIndex, setHoverIndex] = useState(null); const [pinnedIndex, setPinnedIndex] = useState(null); const activeIndex = pinnedIndex ?? hoverIndex; const togglePin = (idx: number) => { if (!Number.isFinite(idx)) return; setPinnedIndex((prev) => (prev === idx ? null : idx)); }; const fetchTimeline = useCallback( async (selectedRange: TimeRange) => { setLoading(true); setError(null); try { const nonce = (window as any).wpApiSettings?.nonce || ''; const res = await fetch( `${apiRoot}${apiNamespace}/visibility/timeline?range=${selectedRange}`, { headers: { 'X-WP-Nonce': nonce } } ); if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); } catch { setError(__('Failed to load timeline data.', 'llmagnet-llm-txt-generator')); } finally { setLoading(false); } }, [apiRoot, apiNamespace] ); useEffect(() => { fetchTimeline(range); }, [range, fetchTimeline]); // Build the full date-range array with real + projected carry-forward points const chartData = useMemo( () => buildChartData(data?.points ?? [], rangeTodays(range)), [data, range] ); // Only 1 real point is enough — carry-forward fills the rest const isEmpty = chartData.filter((p) => p.score !== null).length < 1; // XAxis tick formatter — show a label every N days depending on range // interval={0} on XAxis forces all ticks to render; we return '' to hide them const tickFmt = (val: string, index: number) => { const total = chartData.length; const step = total > 60 ? 30 // 90d → label every 30 days : total > 14 ? 5 // 30d → label every 5 days : 1; // 7d → label every day if (index % step !== 0) return ''; return fmtDate(val); }; return (
{/* Header */}

{__('Visibility Score Over Time', 'llmagnet-llm-txt-generator')}

{RANGES.map(({ label, value }) => ( ))}
{(!(!loading && data && !isEmpty)) && (

{__('Visibility score over time', 'llmagnet-llm-txt-generator')}

)} {/* Chart area */}
{loading && (
)} {!loading && error && (

{error}

)} {!loading && !error && isEmpty && (

{__('Not enough visibility history yet. Keep tracking to see trends.', 'llmagnet-llm-txt-generator')}

)} {!loading && !error && !isEmpty && ( { if (pinnedIndex != null) return; const idx = state?.activeTooltipIndex; setHoverIndex(Number.isFinite(idx) ? idx : null); }} onMouseLeave={() => { if (pinnedIndex == null) setHoverIndex(null); }} onClick={(state: any) => { const idx = state?.activeTooltipIndex; if (Number.isFinite(idx)) togglePin(idx); }} > } cursor={} /> {/* Gradient fill — real data only */} {/* Solid line — real data only */} ( )} activeDot={false} isAnimationActive={false} connectNulls={false} /> {/* Dashed line — projected segment (junction → today) only */} ( )} activeDot={false} isAnimationActive={false} connectNulls={false} /> )}
); }