import { useState, useEffect, useMemo, memo } from 'react'; import { __, sprintf } from '../lib/i18n'; import { LoadingSpinner } from './screen-loader'; import { debugLog } from '../lib/debug'; import { formatCompactNumber } from '../lib/utils'; import { botLogoUrl } from '../lib/bot-logos'; import { ResponsiveContainer, ComposedChart, CartesianGrid, Line, Area, Bar, Rectangle, XAxis, YAxis, Tooltip, } from 'recharts'; type TimeRange = '7d' | '30d' | '90d'; export interface BotFilterContext { botFilterIcons: React.ReactNode; activeBots: Set; allBots: string[]; botLogos: Record; toggleBot: (botName: string) => void; selectAllBots: () => void; clearAllBots: () => void; } interface BotTrafficChartProps { apiRoot: string; apiNamespace: string; renderHeader?: (context: BotFilterContext) => React.ReactNode; timeRange?: TimeRange; // Optional time range prop (defaults to 30d) filterBots?: string[]; // Optional list of bots to show (limits which bots are displayed) /** Fired when the chart model filter changes (for PDF / exports). */ onActiveBotsChange?: (activeBots: string[]) => void; } interface ChartDataPoint { day: string; v: number; } // Generate demo data for last 30 days const generateDemoData = (): ChartDataPoint[] => { const data: ChartDataPoint[] = []; const today = new Date(); for (let i = 29; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); // Generate some realistic-looking traffic data const baseTraffic = 100 + Math.floor(Math.random() * 200); const trend = Math.floor((29 - i) * 15); // Upward trend const v = baseTraffic + trend + Math.floor(Math.random() * 100); data.push({ day: dayLabel, v }); } return data; }; const demoData: ChartDataPoint[] = generateDemoData(); interface TooltipProps { active?: boolean; payload?: Array<{ value: number }>; label?: string; } function CustomTooltip({ active, payload }: TooltipProps) { if (!active || !payload?.length) return null; const value = payload[0].value; return (
{sprintf(__('%s Visits', 'llmagnet-llm-txt-generator'), formatCompactNumber(value))}
); } interface HoverBarCursorProps { viewBox?: { x?: number; y?: number; width?: number; height?: number }; points?: Array<{ x: number; y: number }>; width?: number; height?: number; } /** * Hover bar geometry (value-based): from bottom of plot area up to the point. */ function getHoverBarGeometry({ cursorProps, barWidth = 44, dotR = 12, dotStroke = 7, }: { cursorProps: HoverBarCursorProps; barWidth?: number; dotR?: number; dotStroke?: number; }) { const vb = cursorProps?.viewBox; const points = Array.isArray(cursorProps?.points) ? cursorProps.points : []; // Recharts may pass multiple points (Area + Line). Choose the LOWEST (largest y) const p = points.length ? points.reduce( (acc, cur) => (cur?.y > (acc?.y ?? -Infinity) ? cur : acc), points[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 : cursorProps?.width; const h = typeof vb?.height === 'number' ? vb.height : cursorProps?.height; if (!p || typeof p.x !== 'number' || typeof p.y !== 'number') return null; if (typeof h !== 'number') return null; const bottomY = y0 + h; // p.y is the CENTER of the dot. We want the bar to TOUCH the dot (outer edge), not overlap it. const dotOuter = dotR + dotStroke / 2; const startY = Math.min(bottomY, p.y + dotOuter); const height = Math.max(0, bottomY - startY); // Center bar under hovered point const x = p.x - barWidth / 2; // Clamp inside plot area horizontally when width is known const maxX = typeof w === 'number' ? x0 + w - barWidth : x; const clampedX = typeof w === 'number' ? Math.max(x0, Math.min(x, maxX)) : x; return { x: clampedX, startY, height, barWidth, bottomY }; } /** * Hover cursor: value-based bar with flat top, rounded bottom. */ function HoverBarCursor(props: HoverBarCursorProps) { const DOT_R = 10; const DOT_STROKE = 5; const geom = getHoverBarGeometry({ cursorProps: props, barWidth: 44, dotR: DOT_R, dotStroke: DOT_STROKE, }); if (!geom || geom.height <= 1) return null; const { x: barX, startY, height: barH, barWidth } = geom; // Round ONLY bottom corners (top is flat so it cannot exceed the dot) const r = Math.min(18, barWidth / 2, barH / 2); const xL = barX; const xR = barX + barWidth; const yT = startY; const 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 ( ); } /** Models offered in the filter. */ const CURATED_BOTS = [ 'ChatGPT', 'Claude', 'Perplexity', 'Gemini', 'Grok', 'Bing', 'Llama', 'Mistral', 'DeepSeek', 'Other LLM', ]; const botLogos = new Proxy({} as Record, { get(_t, name: string) { return botLogoUrl(String(name)); }, }); // Helper to get days from time range const getTimeRangeDays = (range: TimeRange): number => { switch (range) { case '7d': return 7; case '30d': return 30; case '90d': return 90; default: return 30; } }; export const BotTrafficChart = memo(function BotTrafficChart({ apiRoot, apiNamespace, renderHeader, timeRange = '30d', filterBots, onActiveBotsChange }: BotTrafficChartProps) { const [chartData, setChartData] = useState([]); const [rawData, setRawData] = useState(null); // Store raw API response for filtering const [isLoading, setIsLoading] = useState(true); const [loadError, setLoadError] = useState(null); const allBotsInSystem = useMemo(() => CURATED_BOTS, []); const selectableBots = useMemo(() => new Set(allBotsInSystem), [allBotsInSystem]); const [activeBots, setActiveBots] = useState>(() => new Set(allBotsInSystem)); /** * Whether a `/stats` row belongs in the chart. * * Rows the filter cannot list — bots outside the curated models — always * count. Excluding them would draw a total smaller than the traffic the site * actually received. */ const includesBot = (botName: string): boolean => activeBots.has(botName) || !selectableBots.has(botName); // Hover and pin states for chart interactions const [hoverIndex, setHoverIndex] = useState(null); const [pinnedIndex, setPinnedIndex] = useState(null); const activeIndex = pinnedIndex ?? hoverIndex; const setFromIndex = (idx: number | null) => { setHoverIndex(idx !== null && Number.isFinite(idx) ? idx : null); }; const togglePin = (idx: number) => { if (!Number.isFinite(idx)) return; setPinnedIndex((prev) => (prev === idx ? null : idx)); }; // Custom Dot component for hover/click interactions const Dot = (props: any) => { const { cx, cy, index } = props; if (cx == null || cy == null) return null; const isActive = index === activeIndex; return ( { if (pinnedIndex == null) setFromIndex(index); }} onMouseLeave={() => { if (pinnedIndex == null) setHoverIndex(null); }} onClick={() => togglePin(index)} /> ); }; // Toggle bot filter const toggleBot = (botName: string) => { setActiveBots(prev => { const newSet = new Set(prev); if (newSet.has(botName)) { newSet.delete(botName); } else { newSet.add(botName); } return newSet; }); }; // Select all bots const selectAllBots = () => { setActiveBots(new Set(allBotsInSystem)); }; // Clear all bots const clearAllBots = () => { setActiveBots(new Set()); }; // Reprocess data when active bots change useEffect(() => { if (rawData) { processDataWithFilters(rawData); } }, [activeBots]); useEffect(() => { onActiveBotsChange?.(Array.from(activeBots).sort()); }, [activeBots, onActiveBotsChange]); const days = getTimeRangeDays(timeRange); // Generate array of days with 0 values based on time range const generateEmptyDays = (): ChartDataPoint[] => { const data: ChartDataPoint[] = []; const today = new Date(); for (let i = days - 1; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); data.push({ day: dayLabel, v: 0 }); } return data; }; // Fill in missing days with 0 values based on time range const fillMissingDays = (dailyTotals: Record): ChartDataPoint[] => { const today = new Date(); const result: ChartDataPoint[] = []; // Helper to format date in local time (not UTC) const formatDateKey = (date: Date) => { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }; for (let i = days - 1; i >= 0; i--) { const date = new Date(today); date.setDate(date.getDate() - i); const dateKey = formatDateKey(date); // YYYY-MM-DD in local time const dayLabel = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); result.push({ day: dayLabel, v: dailyTotals[dateKey] || 0, }); } return result; }; const processDataWithFilters = (data: any) => { debugLog('Processing data:', data); debugLog('Active bots:', Array.from(activeBots)); // If no bots are selected, show empty chart (all zeros) if (activeBots.size === 0) { debugLog('No bots selected, showing empty chart'); setChartData(generateEmptyDays()); return; } // The API returns an array of { bot_name, date, visits } objects // We need to: 1) filter by active bots, 2) group by date, 3) sum visits per date // Check if data is an array (direct API response format) if (Array.isArray(data) && data.length > 0) { debugLog('Data is array format, processing...'); // Filter visits by active bots only const filteredVisits = data.filter((visit: any) => { const botName = visit.bot_name || visit.bot; return botName && includesBot(botName); }); debugLog('Filtered visits count:', filteredVisits.length); // Group by date and sum visits const dailyTotals: Record = {}; filteredVisits.forEach((visit: any) => { const dateKey = visit.date; // Convert visits to number properly (handle string values) const visitsNum = parseFloat(String(visit.visits)) || 0; dailyTotals[dateKey] = (dailyTotals[dateKey] || 0) + visitsNum; }); debugLog('Daily totals:', dailyTotals); // Fill in all 30 days (missing days get 0) const processedData = fillMissingDays(dailyTotals); debugLog('Processed chart data (30 days):', processedData); setChartData(processedData); return; } // Handle object with daily_stats property if (data && data.daily_stats && Array.isArray(data.daily_stats) && data.daily_stats.length > 0) { const dailyTotals: Record = {}; data.daily_stats.forEach((dayData: any) => { const dateKey = dayData.date; let totalVisits = 0; if (dayData.bots && typeof dayData.bots === 'object') { Object.entries(dayData.bots).forEach(([botName, visits]) => { if (includesBot(botName)) { totalVisits += parseFloat(String(visits)) || 0; } }); } else { totalVisits = parseFloat(String(dayData.total_visits || dayData.visits)) || 0; } dailyTotals[dateKey] = totalVisits; }); const processedData = fillMissingDays(dailyTotals); debugLog('Processed daily_stats (30 days):', processedData); setChartData(processedData); return; } // Handle object with visits array property if (data && data.visits && Array.isArray(data.visits) && data.visits.length > 0) { const filteredVisits = data.visits.filter((visit: any) => { const botName = visit.bot_name || visit.bot; return botName && includesBot(botName); }); const dailyTotals: Record = {}; filteredVisits.forEach((visit: any) => { const dateKey = visit.date; const visitsNum = parseFloat(String(visit.visits || visit.total_visits)) || 0; dailyTotals[dateKey] = (dailyTotals[dateKey] || 0) + visitsNum; }); const processedData = fillMissingDays(dailyTotals); debugLog('Processed visits array (30 days):', processedData); setChartData(processedData); return; } // No data available - show empty chart with zeros debugLog('No valid data format found, showing empty chart'); setChartData(generateEmptyDays()); }; useEffect(() => { // For now, use demo data if localStorage test mode is enabled if (localStorage.getItem('llmagnet_test') === 'true') { setChartData(demoData); setIsLoading(false); return; } // Fetch real data from API fetchChartData(); }, [timeRange]); // Refetch when timeRange changes const fetchChartData = async () => { try { setIsLoading(true); setLoadError(null); if (!(window as any).wpApiSettings || !(window as any).wpApiSettings.root) { // Do NOT silently show fake numbers to paying users. Surface a clear // error state and render an empty (zeroed) chart instead. setChartData(generateEmptyDays()); setLoadError(__('We couldn\'t load your analytics (missing API configuration). Please reload the page.', 'llmagnet-llm-txt-generator')); setIsLoading(false); return; } // Calculate date range based on timeRange const endDate = new Date(); const startDate = new Date(); startDate.setDate(startDate.getDate() - days); const formatDate = (date: Date) => { // Use local date to avoid timezone issues (not UTC) const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; // YYYY-MM-DD format in local time }; const apiUrl = `${apiRoot}${apiNamespace}/stats?start_date=${formatDate(startDate)}&end_date=${formatDate(endDate)}`; debugLog('Fetching chart data from:', apiUrl); const response = await fetch(apiUrl, { headers: { 'X-WP-Nonce': (window as any).wpApiSettings?.nonce || '', }, credentials: 'same-origin', }); if (!response.ok) { debugLog('API response not ok:', response.status); setChartData(generateEmptyDays()); setLoadError(__('We couldn\'t load your analytics right now. Please try again in a moment.', 'llmagnet-llm-txt-generator')); setIsLoading(false); return; } const data = await response.json(); debugLog('API response data:', data); // Store raw data for filtering setRawData(data); // Process with current filters processDataWithFilters(data); } catch (err) { console.error('Error fetching chart data:', err); setChartData(generateEmptyDays()); setLoadError(__('We couldn\'t load your analytics right now. Please try again in a moment.', 'llmagnet-llm-txt-generator')); } finally { setIsLoading(false); } }; // If filterBots is provided, only show those bots; otherwise show all const displayBots = filterBots && filterBots.length > 0 ? allBotsInSystem.filter(bot => filterBots.includes(bot)) : allBotsInSystem; // Bot filter icons component - can be rendered externally via renderHeader prop const botFilterIcons = (
{displayBots.map((botName, index) => { const isActive = activeBots.has(botName); return (
{botName} toggleBot(botName)} onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(-3px)'; (e.currentTarget as HTMLElement).style.zIndex = '20'; }} onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.transform = 'translateY(0)'; (e.currentTarget as HTMLElement).style.zIndex = String(index); }} />
); })}
); // Create the filter context for renderHeader const filterContext: BotFilterContext = { botFilterIcons, activeBots, allBots: displayBots, botLogos, toggleBot, selectAllBots, clearAllBots, }; return (
{/* Render header with bot icons if renderHeader prop is provided, otherwise render icons here */} {renderHeader ? renderHeader(filterContext) : (
{botFilterIcons}
)} {/* Chart */}
{isLoading ? (
) : loadError ? (

{__('Analytics unavailable', 'llmagnet-llm-txt-generator')}

{loadError}

) : activeBots.size === 0 ? (

{__('Choose bot models to show data', 'llmagnet-llm-txt-generator')}

) : ( { if (pinnedIndex != null) return; const idx = state?.activeTooltipIndex; if (Number.isFinite(idx)) { setFromIndex(idx); return; } const label = state?.activeLabel; if (label != null) { const mapped = chartData.findIndex((d) => d.day === label); setFromIndex(mapped >= 0 ? mapped : null); } }} onMouseLeave={() => { if (pinnedIndex == null) setHoverIndex(null); }} onClick={(state: any) => { const idx = state?.activeTooltipIndex; if (Number.isFinite(idx)) togglePin(idx); }} > {/* Bars (always visible) - subtle fill */} {/* Hover column gradients */} { // Show fewer labels to avoid crowding (every 5th day) if (chartData.length > 15) { return index % 5 === 0 ? value : ''; } return value; }} /> {/* Hover tooltip + hover cursor bar */} } cursor={} /> {/* Bars for each point - only active one is visible */} { const isActive = props.index === activeIndex; return ( ); }} /> } activeDot={false} isAnimationActive={false} /> )}
); });