/** * Bot Analytics screen (improvement-plan P2-2.1). * * Formerly a single 1,758-line dashboard-analytics.tsx; now split into: * - constants.ts shared types, bot registry, label helpers * - traffic-chart-card.tsx lazy chart card (BotTrafficChart wrapper) * - time-range-filter.tsx model multi-select + date-range picker header * - bot-summary-table.tsx per-bot visits/clicks/CTR/trend table * * This file keeps all data fetching and state; rendering and props flow are * unchanged. Public import path (`components/dashboard-analytics`) and the * `DashboardAnalytics` export are preserved. */ import React, { useState, useEffect, useRef, useCallback } from 'react'; import Chart from 'chart.js/auto'; import { BotAnalyticsSkeleton } from '../data-skeleton'; import { debugLog } from '../../lib/debug'; import { ReportPdfDownloadButton } from '../report-pdf-download-button'; import { createBotAnalyticsPdf } from '../../lib/report-pdf'; import { trackTimeRangeChanged, setUserProperty } from '../../lib/mixpanel'; import { getSiteNameForReports } from '../../lib/site-name'; import { __ } from '../../lib/i18n'; import { SUPPORTED_BOTS, BOT_LOGOS, BOT_COLORS, getDefaultBotLogo, getTimeRangeLabel, getChartTrafficWindowLabel, } from './constants'; import type { BotVisit, BotSummary, BotStat, TimeRangeType } from './constants'; import { TrafficChartCard } from './traffic-chart-card'; import { BotSummaryTable } from './bot-summary-table'; interface DashboardAnalyticsProps { pluginUrl: string; apiRoot: string; apiNamespace: string; filterBots?: string[]; // Optional: filter to show only specific bots showFullStatsLink?: boolean; // Optional: show link to full statistics page } export function DashboardAnalytics({ apiRoot, apiNamespace, pluginUrl, filterBots, showFullStatsLink = false, }: DashboardAnalyticsProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [botVisits, setBotVisits] = useState([]); const [botSummaries, setBotSummaries] = useState([]); const [botStats, setBotStats] = useState([]); // For clicks data // Get saved time range from localStorage or default to 30days const getSavedTimeRange = (): TimeRangeType => { if (typeof window === 'undefined') return '30days'; const saved = localStorage.getItem('llmagnet_timerange'); return (saved as TimeRangeType) || '30days'; }; const [timeRange, setTimeRange] = useState(getSavedTimeRange()); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [showDatePicker, setShowDatePicker] = useState(false); const [showModelDropdown, setShowModelDropdown] = useState(false); const [customStartDate, setCustomStartDate] = useState(''); const [customEndDate, setCustomEndDate] = useState(''); // Initialize custom dates with last 30 days React.useEffect(() => { const today = new Date(); const thirtyDaysAgo = new Date(today); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); setCustomEndDate(today.toISOString().split('T')[0]); setCustomStartDate(thirtyDaysAgo.toISOString().split('T')[0]); }, []); const mainChartRef = useRef(null); const mainChartInstance = useRef(null); const chartRefs = useRef>(new Map()); const chartInstances = useRef>(new Map()); const [chartActiveBots, setChartActiveBots] = useState([]); // Function to generate empty data with zeros for all visits const generateEmptyData = () => { // Generate dates for the last 30 days const dates: string[] = []; const today = new Date(); for (let i = 29; i >= 0; i--) { const date = new Date(today); date.setDate(today.getDate() - i); dates.push(date.toISOString().split('T')[0]); // Format as YYYY-MM-DD } // Use the centralized list of supported bots const bots = [...SUPPORTED_BOTS]; const emptyData: BotVisit[] = []; // Generate zero visits for each bot on each date bots.forEach(bot => { dates.forEach(date => { emptyData.push({ bot_name: bot, date, visits: 0 }); }); }); return emptyData; }; useEffect(() => { debugLog('API Root:', apiRoot); debugLog('API Namespace:', apiNamespace); debugLog('Current time range:', timeRange); // Function to fetch real data from API const fetchData = async () => { try { debugLog('Attempting to fetch data from API...'); // Get date ranges for current and previous periods const dateRanges = getDateRanges(); debugLog('Date ranges:', dateRanges); // Ensure API endpoint is correctly formatted with WordPress REST API path const endpoint = '/wp-json/llm-analytics/v1/stats'; debugLog('Using endpoint:', endpoint); debugLog('WP API Settings:', window.wpApiSettings); // Try to get nonce from window.wpApiSettings const nonce = window.wpApiSettings?.nonce || ''; debugLog('Nonce available:', nonce ? 'Yes' : 'No'); // Fetch current period data debugLog('Fetching current period data:', dateRanges.current); const currentResponse = await fetch(`${endpoint}?start_date=${encodeURIComponent(dateRanges.current.start)}&end_date=${encodeURIComponent(dateRanges.current.end)}`, { headers: { 'X-WP-Nonce': nonce }, credentials: 'same-origin' // Include cookies in the request }); // Fetch previous period data debugLog('Fetching previous period data:', dateRanges.previous); const previousResponse = await fetch(`${endpoint}?start_date=${encodeURIComponent(dateRanges.previous.start)}&end_date=${encodeURIComponent(dateRanges.previous.end)}`, { headers: { 'X-WP-Nonce': nonce }, credentials: 'same-origin' // Include cookies in the request }); debugLog('API Response Status (current):', currentResponse.status); debugLog('API Response Status (previous):', previousResponse.status); if (!currentResponse.ok || !previousResponse.ok) { console.error(`API error: ${currentResponse.status} or ${previousResponse.status}`); // Real API failure — show a clear error state, not silent zeros. useEmptyOrMockData(true); return; } const currentData = await currentResponse.json(); const previousData = await previousResponse.json(); debugLog('API Response Data (current):', currentData); debugLog('API Response Data (previous):', previousData); const currentTypedData = currentData as BotVisit[]; const previousTypedData = previousData as BotVisit[]; // If current data is empty or not an array, use empty data if (!currentTypedData || !Array.isArray(currentTypedData)) { debugLog('No current analytics data available, using empty data'); useEmptyOrMockData(); return; } debugLog('Valid data received from API, processing...'); setBotVisits(currentTypedData); // Use the centralized list of supported bots const allSupportedBots = [...SUPPORTED_BOTS]; // Calculate total visits for each bot (current period) const botTotals: Record = {}; // Calculate total visits for each bot (previous period) const botPreviousTotals: Record = {}; // Initialize all bots with zero visits for both periods allSupportedBots.forEach(bot => { botTotals[bot] = 0; botPreviousTotals[bot] = 0; }); // Add actual visit counts from current period data currentTypedData.forEach((visit: BotVisit) => { // Convert to number to prevent string concatenation - use parseFloat for safety const visitsNum = parseFloat(String(visit.visits)) || 0; if (botTotals[visit.bot_name] !== undefined) { // Ensure both values are numbers before addition const currentTotal = Number(botTotals[visit.bot_name]) || 0; botTotals[visit.bot_name] = currentTotal + visitsNum; // Debug log for troubleshooting if (visit.bot_name === 'ChatGPT') { debugLog(`ChatGPT: Adding ${visitsNum} (from ${visit.visits}) to ${currentTotal} = ${botTotals[visit.bot_name]}`); } } else { // Handle any bot names from API that aren't in our predefined list botTotals[visit.bot_name] = visitsNum; // Add to supported bots if not already included if (!allSupportedBots.includes(visit.bot_name)) { allSupportedBots.push(visit.bot_name); // Initialize previous total for new bot botPreviousTotals[visit.bot_name] = 0; } } }); // Add actual visit counts from previous period data if (previousTypedData && Array.isArray(previousTypedData)) { previousTypedData.forEach((visit: BotVisit) => { // Convert to number to prevent string concatenation - use parseFloat for safety const visitsNum = parseFloat(String(visit.visits)) || 0; if (botPreviousTotals[visit.bot_name] !== undefined) { // Ensure both values are numbers before addition botPreviousTotals[visit.bot_name] = Number(botPreviousTotals[visit.bot_name]) + visitsNum; } else { // Handle any bot names from API that aren't in our predefined list botPreviousTotals[visit.bot_name] = visitsNum; // Add to supported bots if not already included if (!allSupportedBots.includes(visit.bot_name)) { allSupportedBots.push(visit.bot_name); // Initialize current total for new bot botTotals[visit.bot_name] = 0; } } }); } debugLog('Bot totals calculated (current):', botTotals); debugLog('Bot totals calculated (previous):', botPreviousTotals); debugLog('All supported bots:', allSupportedBots); // Calculate percentage change for each bot const calculatePercentageChange = (current: number, previous: number): number => { if (previous === 0 && current === 0) return 0; if (previous === 0 && current > 0) return 100; return Math.round(((current - previous) / previous) * 100); }; // Create summaries with totals for all bots // Use the SUPPORTED_BOTS array directly for ordering instead of sorting const summaries: BotSummary[] = allSupportedBots.map(bot => ({ bot_name: bot, total_visits: Number(botTotals[bot] || 0), previous_visits: Number(botPreviousTotals[bot] || 0), percentage_change: calculatePercentageChange( Number(botTotals[bot] || 0), Number(botPreviousTotals[bot] || 0) ), logo: BOT_LOGOS[bot] || getDefaultBotLogo(), })); // Process real data for premium users debugLog('Processing real data for premium user'); // Filter bots if filterBots prop is provided const filteredSummaries = filterBots && filterBots.length > 0 ? summaries.filter(bot => filterBots.includes(bot.bot_name)) : summaries; setBotSummaries(filteredSummaries); const totalVisits = filteredSummaries.reduce((sum, b) => sum + b.total_visits, 0); setUserProperty({ total_bot_visits_30d: totalVisits }); setIsLoading(false); } catch (err) { console.error('Error fetching analytics data:', err); // Network/parse failure — show a clear error state, not silent zeros. useEmptyOrMockData(true); } }; // Function to use empty data with zeros. When `isErrorState` is true the // call comes from an actual API/network failure, so surface a clear error // banner instead of silently presenting zeros as if they were real data. const useEmptyOrMockData = (isErrorState = false) => { debugLog('Using empty data as fallback', { isErrorState }); // Use the centralized list of supported bots const allSupportedBots = [...SUPPORTED_BOTS]; // Use mock data but with zero visits const emptyData = generateEmptyData(); setBotVisits(emptyData); // Create summaries with zero totals for all supported bots - preserve order const emptySummaries = allSupportedBots.map(bot => ({ bot_name: bot, total_visits: 0, previous_visits: 0, percentage_change: 0, logo: BOT_LOGOS[bot] || getDefaultBotLogo(), })); // Filter bots if filterBots prop is provided const filteredEmptySummaries = filterBots && filterBots.length > 0 ? emptySummaries.filter(bot => filterBots.includes(bot.bot_name)) : emptySummaries; setBotSummaries(filteredEmptySummaries); setIsLoading(false); if (isErrorState) { setError(__('We couldn\'t load your analytics right now. Please reload the page or try again shortly.', 'llmagnet-llm-txt-generator')); } else { setError(null); // Clear any previous errors } }; fetchData(); }, [apiRoot, apiNamespace, timeRange, startDate, endDate, filterBots]); // Fetch bot stats (impressions, clicks, ctr, trend) const fetchBotStats = async () => { try { const nonce = window.wpApiSettings?.nonce || ''; const endpoint = '/wp-json/llm-analytics/v1/bot-stats-table'; const response = await fetch(endpoint, { headers: { 'X-WP-Nonce': nonce }, credentials: 'same-origin' }); if (!response.ok) { console.error('Failed to fetch bot stats:', response.status); return; } const data = await response.json(); // Filter bot stats if filterBots prop is provided const filteredBotStats = filterBots && filterBots.length > 0 ? data.filter((stat: BotStat) => filterBots.includes(stat.bot_name)) : data; setBotStats(filteredBotStats); } catch (error) { console.error('Error fetching bot stats:', error); } }; // Fetch bot stats when component mounts or filterBots changes useEffect(() => { fetchBotStats(); }, [filterBots]); // Initialize custom date range from localStorage useEffect(() => { // Set default dates if not already set if (timeRange === 'custom') { const savedStart = localStorage.getItem('llmagnet_custom_start'); const savedEnd = localStorage.getItem('llmagnet_custom_end'); if (savedStart) { setStartDate(savedStart); } else { // Default to 30 days ago const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); setStartDate(thirtyDaysAgo.toISOString().split('T')[0]); } if (savedEnd) { setEndDate(savedEnd); } else { // Default to today setEndDate(new Date().toISOString().split('T')[0]); } } }, [timeRange]); // Re-render main chart when time range changes useEffect(() => { if (!isLoading && mainChartRef.current) { debugLog('Time range changed, re-rendering main chart'); trackTimeRangeChanged(timeRange, 'bot_analytics'); renderMainChart(); } }, [timeRange, startDate, endDate]); // Helper function to get date ranges for current and previous periods const getDateRanges = () => { const daysToShow = getDaysToShow(); const today = new Date(); // Current period let currentEndDate = new Date(); let currentStartDate: Date; // For custom date range, use the exact dates if (timeRange === 'custom' && startDate && endDate) { currentStartDate = new Date(startDate); currentEndDate = new Date(endDate); } else { // For preset ranges currentStartDate = new Date(today); currentStartDate.setDate(today.getDate() - daysToShow + 1); // +1 to include today } // Previous period (same length as current period) const periodLength = Math.floor((currentEndDate.getTime() - currentStartDate.getTime()) / (24 * 60 * 60 * 1000)); const prevEndDate = new Date(currentStartDate); prevEndDate.setDate(prevEndDate.getDate() - 1); // Previous day from current start const prevStartDate = new Date(prevEndDate); prevStartDate.setDate(prevEndDate.getDate() - periodLength); return { current: { start: currentStartDate.toISOString().split('T')[0], end: currentEndDate.toISOString().split('T')[0] }, previous: { start: prevStartDate.toISOString().split('T')[0], end: prevEndDate.toISOString().split('T')[0] } }; }; // Helper function to calculate days to show based on time range const getDaysToShow = () => { switch (timeRange) { case '24hours': return 1; case '7days': return 7; case '14days': return 14; case '30days': return 30; case '90days': return 90; case 'alltime': return 365; // Use a large number for "all time" case 'custom': if (startDate && endDate) { const start = new Date(startDate); const end = new Date(endDate); const diffTime = Math.abs(end.getTime() - start.getTime()); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); return diffDays + 1; // Include both start and end dates } return 30; // Default to 30 days if custom dates not set default: return 30; } }; // Render the main chart showing all bots const renderMainChart = () => { const mainCanvas = document.getElementById('main-chart') as HTMLCanvasElement; if (!mainCanvas) { console.error('Main chart canvas not found'); return; } debugLog('Rendering main chart'); // Destroy existing chart if it exists if (mainChartInstance.current) { mainChartInstance.current.destroy(); } // Generate dates for the selected time period const daysToShow = getDaysToShow(); // Generate all dates in the range for consistent x-axis const allDates: string[] = []; // For custom date range, use the exact dates if (timeRange === 'custom' && startDate && endDate) { const currentDate = new Date(startDate); const lastDate = new Date(endDate); while (currentDate <= lastDate) { allDates.push(currentDate.toISOString().split('T')[0]); currentDate.setDate(currentDate.getDate() + 1); } } else { // For preset ranges const today = new Date(); const startDate = new Date(today); startDate.setDate(today.getDate() - daysToShow + 1); // +1 to include today for (let i = 0; i < daysToShow; i++) { const date = new Date(startDate); date.setDate(startDate.getDate() + i); allDates.push(date.toISOString().split('T')[0]); } } const startDateStr = allDates[0]; const filteredVisits = botVisits.filter(visit => visit.date >= startDateStr); // Get unique bot names from filtered data const botNames = Array.from(new Set(filteredVisits.map(visit => visit.bot_name))).sort(); // Prepare datasets for each bot with zero values for missing dates const datasets = botNames.map(botName => { const botData = allDates.map(date => { const matches = filteredVisits.filter(visit => visit.date === date && visit.bot_name === botName ); return matches.reduce((sum, visit) => sum + (parseFloat(String(visit.visits)) || 0), 0); }); return { label: botName, data: botData, backgroundColor: BOT_COLORS[botName] || '#999', borderColor: BOT_COLORS[botName] || '#999', borderWidth: 1.5, tension: 0.3, fill: false, pointRadius: 2, }; }); // Create chart mainChartInstance.current = new Chart(mainCanvas, { type: 'line', data: { labels: allDates, datasets, }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'top', labels: { boxWidth: 12, font: { size: 10 } } }, tooltip: { mode: 'index', intersect: false } }, scales: { y: { beginAtZero: true, ticks: { precision: 0 }, grid: { color: 'rgba(0, 0, 0, 0.05)' } }, x: { ticks: { maxRotation: 0, autoSkip: true, maxTicksLimit: timeRange === '7days' ? 7 : 10, font: { size: 9 } }, grid: { display: false } } }, animation: { duration: 1000 } } }); }; // Render individual bot charts const renderBotChart = (botName: string) => { const canvas = chartRefs.current.get(botName); if (!canvas) { console.error(`Canvas for ${botName} not available`); return; } debugLog(`Rendering chart for ${botName}`); // Destroy existing chart if it exists if (chartInstances.current.get(botName)) { chartInstances.current.get(botName).destroy(); } // Generate dates for the selected time period const daysToShow = getDaysToShow(); const allDates: string[] = []; // For custom date range, use the exact dates if (timeRange === 'custom' && startDate && endDate) { const currentDate = new Date(startDate); const lastDate = new Date(endDate); while (currentDate <= lastDate) { allDates.push(currentDate.toISOString().split('T')[0]); currentDate.setDate(currentDate.getDate() + 1); } } else { // For preset ranges for (let i = daysToShow - 1; i >= 0; i--) { const date = new Date(); date.setDate(date.getDate() - i); allDates.push(date.toISOString().split('T')[0]); } } const startDateStr = allDates[0]; // Filter visits for this bot in the date range const filteredVisits = botVisits.filter( visit => visit.bot_name === botName && visit.date >= startDateStr ); // Prepare data with zeros for missing dates const data = allDates.map(date => { const matches = filteredVisits.filter(visit => visit.date === date); return matches.reduce((sum, visit) => sum + (Number(visit.visits) || 0), 0); }); // Find the max value to determine if we need to show a flat line const maxValue = Math.max(...data); // Create mini chart chartInstances.current.set(botName, new Chart(canvas, { type: 'line', data: { labels: allDates, datasets: [{ label: botName, data: data, backgroundColor: BOT_COLORS[botName] || '#999', borderColor: BOT_COLORS[botName] || '#999', borderWidth: 1.5, tension: 0.3, pointRadius: 0, fill: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { enabled: false } }, scales: { y: { display: false, beginAtZero: true, // If all values are 0, set a min of 0 and max of 1 to show a flat line min: 0, max: maxValue === 0 ? 1 : undefined, grid: { display: false } }, x: { display: false, grid: { display: false }, ticks: { maxTicksLimit: timeRange === '7days' ? 7 : 15 } } }, elements: { point: { radius: 0 }, line: { borderWidth: 1.5 } }, animation: { duration: 800 } } })); }; // Directly render charts after component is mounted and data is ready useEffect(() => { if (!isLoading && botSummaries.length > 0) { debugLog('Attempting to render charts...'); debugLog('Rendering charts with bundled Chart.js'); setTimeout(renderAllCharts, 300); } }, [isLoading, botSummaries, timeRange]); const buildBotAnalyticsPdfDoc = useCallback(async () => { const siteName = getSiteNameForReports(); const metaLines = [ `Analytics period: ${getTimeRangeLabel(timeRange, startDate, endDate)}`, `Traffic chart window: ${getChartTrafficWindowLabel(timeRange)}`, chartActiveBots.length === 0 ? 'Models in chart filter: none selected' : `Models in chart filter: ${chartActiveBots.join(', ')}`, ]; const tableRows = botSummaries.map((bot) => { const botStat = botStats.find((stat) => stat.bot_name === bot.bot_name); const trendValue = botStat?.trend ?? bot.percentage_change ?? 0; return { bot: bot.bot_name, visits: Number(bot.total_visits).toLocaleString(), clicks: (botStat?.clicks ?? 0).toLocaleString(), ctr: `${(botStat?.ctr ?? 0).toFixed(0)}%`, trend: `${trendValue > 0 ? '+' : ''}${Math.abs(trendValue).toFixed(0)}%`, }; }); return createBotAnalyticsPdf({ pluginUrl, siteName, reportTitle: 'Bot analytics', metaLines, tableRows, }); }, [ pluginUrl, timeRange, startDate, endDate, chartActiveBots, botSummaries, botStats, ]); // Function to render all charts const renderAllCharts = () => { debugLog('Rendering all charts now'); // Render main chart const mainCanvas = document.getElementById('main-chart') as HTMLCanvasElement; if (mainCanvas) { // Use the canvas directly rather than assigning to ref.current renderMainChart(); } else { console.error('Main chart canvas not found'); } // Render individual bot charts botSummaries.forEach(bot => { const canvas = document.getElementById(`bot-chart-${bot.bot_name}`) as HTMLCanvasElement; if (canvas) { chartRefs.current.set(bot.bot_name, canvas); renderBotChart(bot.bot_name); } else { console.error(`Canvas element for ${bot.bot_name} not found`); } }); }; return (
{isLoading ? ( ) : error ? (

{error}

) : (
{/* Main chart with time range selector */} {/* Bot summary table */}
)}
); }