import { useCallback, useEffect, useMemo, useState } from 'react'; import { api, type HumanLogRow, type OverviewResponse, type Period } from '@/lib/api'; import { AI_BRAND_MAP } from '@/lib/brands'; // Fallback constants — server-side multiplier/ratio (from /settings) takes precedence // when the overview response includes claudeExtrapolated. These match the backend // defaults and are only used if the API surface hasn't been upgraded yet. export const AI_CRAWLER_FOOTPRINT_MULTIPLIER = 10; export const CLAUDE_HUMAN_FROM_CRAWLER_RATIO = 0.4; export interface AnalyticsShape { mode: 'standalone' | 'connected'; /** False when visitor analytics is switched off, so the UI can explain the empty dashboard. */ tracking_enabled?: boolean; totalHumanVisits: number; aiDiscovery: { totalVisits: number; totalMeasured: number; totalWithEstimate: number; trend: number; previousTotal: number; trendLabel: string; chartData: Array>; engineBreakdown: Array<{ key: string; label: string; count: number; icon?: string; color?: string }>; statusText: string; windowLabel: string; estimatedAiCrawlerFootprint: number; estimatedHumanReach: number; claudeCrawler: number; claudeUserLike: number; claudeUnknown: number; gptCrawler: number; gptUserLike: number; gptUnknown: number; extrapolationMethodology: string; extrapolationMultiplier: number; extrapolationHumanReachRatio: number; }; aiReferredGroups: Array<{ source: string; count: number }>; topPages: { pages: Array<{ url: string; label: string; visits: number; trend: 'up' | 'down' }>; statusText: string; }; nonAiUsers: { pages: Array<{ url: string; label: string; visits: number; trend: 'up' | 'down' }>; statusText: string; }; crawlerLogs: { logs: Array<{ timestamp: string; agent: string; action: string; ip: string }>; isActive: boolean; totalCrawlerCount: number; }; humanLogs: HumanLogRow[]; } function periodLabel(p: Period): string { switch (p) { case '24h': return 'In the last 24 hours'; case '7d': return 'In the last 7 days'; case '30d': return 'In the last 30 days'; case '90d': return 'In the last 90 days'; } } function trendLabelFor(p: Period): string { switch (p) { case '24h': return 'vs previous 24h'; case '7d': return 'vs previous 7 days'; case '30d': return 'vs previous 30 days'; case '90d': return 'vs previous 90 days'; } } function shapeAnalytics(overview: OverviewResponse, period: Period): AnalyticsShape { const mode = overview.mode; const aiVisits = overview.cards.aiVisits; const claudeExtrapolated = aiVisits.claudeExtrapolated; const M = claudeExtrapolated?.multiplier ?? AI_CRAWLER_FOOTPRINT_MULTIPLIER; const humanRatio = claudeExtrapolated?.humanReachRatio ?? CLAUDE_HUMAN_FROM_CRAWLER_RATIO; const formatBucket = (iso: string): string => { const d = new Date(iso); if (period === '24h') { return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true }); } return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); }; const chartData = overview.visibilityTimeline.map((p) => { const claudeScaled = (p.claude || 0) * M; const othersSum = (p.gpt || 0) + (p.google || 0) + (p.perplexity || 0) + (p.bing || 0) + (p.meta || 0) + (p.bytedance || 0) + (p.apple || 0) + (p.amazon || 0) + (p.xai || 0) + (p.deepseek || 0) + (p.mistral || 0) + (p.cohere || 0); return { name: formatBucket(p.timestamp), total: claudeScaled + othersSum, gpt: p.gpt || 0, claude: claudeScaled, google: p.google || 0, perplexity: p.perplexity || 0, bing: p.bing || 0, meta: p.meta || 0, bytedance: p.bytedance || 0, apple: p.apple || 0, amazon: p.amazon || 0, xai: p.xai || 0, deepseek: p.deepseek || 0, mistral: p.mistral || 0, cohere: p.cohere || 0, }; }); const EXCLUDED = new Set(['scanner', 'bot', 'other', 'others', 'normal-user']); const engineBreakdown = aiVisits.attribution .filter((a) => !EXCLUDED.has(a.agent_type) && a.count > 0) .sort((a, b) => b.count - a.count) .map((a) => { const key = a.agent_type; const brand = AI_BRAND_MAP[key]; const raw = a.count; const count = key === 'claude' ? raw * M : raw; return { key, label: brand?.label ?? key, count, icon: brand?.icon, color: brand?.color, }; }); const claudeBreakdown = aiVisits.claudeBreakdown ?? { crawler: 0, userLike: 0, unknown: 0 }; const gptBreakdown = aiVisits.gptBreakdown ?? { crawler: 0, userLike: 0, unknown: 0 }; const estimatedAiCrawlerFootprint = claudeExtrapolated?.crawlerFootprint ?? claudeBreakdown.crawler * M; const estimatedHumanReach = claudeExtrapolated?.humanReachEstimate ?? Math.round(estimatedAiCrawlerFootprint * humanRatio); const claudePill = engineBreakdown.find((e) => e.key === 'claude'); const claudePillCount = claudePill?.count ?? 0; const claudeHumanEstimate = claudePillCount > 0 ? Math.max(1, Math.round(claudePillCount * CLAUDE_HUMAN_FROM_CRAWLER_RATIO)) : 0; const counts = new Map(); const referralSources = overview.features.humanReferralSources ?? []; for (const row of referralSources) { const raw = (row.source || '').trim(); if (!raw) continue; const key = raw.toLowerCase(); if (key === 'direct') continue; counts.set(raw, (counts.get(raw) || 0) + row.count); } const hasClaudeRow = Array.from(counts.keys()).some((k) => k.toLowerCase().includes('claude')); if (hasClaudeRow) { for (const k of Array.from(counts.keys())) { if (k.toLowerCase().includes('claude')) { counts.set(k, Math.max(counts.get(k) ?? 0, claudeHumanEstimate)); } } } else if (claudeHumanEstimate > 0) { counts.set('Claude', claudeHumanEstimate); } const aiReferredGroups = Array.from(counts.entries()) .map(([source, count]) => ({ source, count })) .sort((a, b) => b.count - a.count); const topPagesList = overview.features.pagesByEngine.slice(0, 6).map((p) => ({ url: p.url, label: p.url, visits: p.ai_hits, trend: 'up' as const, })); const nonAiPagesList = overview.features.pagesByEngine .slice() .sort((a, b) => b.human_hits - a.human_hits) .slice(0, 6) .map((p) => ({ url: p.url, label: p.url, visits: p.human_hits, trend: 'up' as const, })); const crawlerLogsList = overview.features.radar.slice(0, 20).map((r) => ({ timestamp: r.timestamp, agent: r.agent_type ?? 'unknown', action: r.url_path, ip: '', })); return { mode, tracking_enabled: overview.tracking_enabled, totalHumanVisits: overview.cards.normalUsers?.total ?? 0, aiDiscovery: { totalVisits: aiVisits.total, totalMeasured: aiVisits.totalMeasured ?? aiVisits.total, totalWithEstimate: aiVisits.totalWithEstimate ?? aiVisits.total, trend: aiVisits.trend, previousTotal: aiVisits.previous, trendLabel: trendLabelFor(period), chartData, engineBreakdown, statusText: aiVisits.total > 0 ? 'AI systems are actively discovering your content' : 'Waiting for AI discovery data', windowLabel: periodLabel(period), estimatedAiCrawlerFootprint, estimatedHumanReach, claudeCrawler: claudeBreakdown.crawler, claudeUserLike: claudeBreakdown.userLike, claudeUnknown: claudeBreakdown.unknown ?? 0, gptCrawler: gptBreakdown.crawler, gptUserLike: gptBreakdown.userLike, gptUnknown: gptBreakdown.unknown ?? 0, extrapolationMethodology: claudeExtrapolated?.methodology ?? 'multiplier-v1', extrapolationMultiplier: M, extrapolationHumanReachRatio: humanRatio, }, aiReferredGroups, topPages: { pages: topPagesList, statusText: `${periodLabel(period)} · top AI hits`, }, nonAiUsers: { pages: nonAiPagesList, statusText: `${periodLabel(period)} · human traffic`, }, crawlerLogs: { logs: crawlerLogsList, isActive: overview.cards.botActivity.lastCrawledAt !== null, totalCrawlerCount: estimatedAiCrawlerFootprint || overview.cards.aiVisits.total, }, humanLogs: overview.features.humanLogs ?? [], }; } export function useAnalytics(period: Period) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const overview = await api.overview(period); setData(shapeAnalytics(overview, period)); } catch (e) { setError(e instanceof Error ? e.message : 'Failed to load analytics'); } finally { setLoading(false); } }, [period]); useEffect(() => { void load(); }, [load]); return useMemo(() => ({ data, loading, error, refresh: load }), [data, loading, error, load]); }