'use client'; import { useState, useEffect, useMemo } from 'react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine, } from 'recharts'; import { TrendingDown, AlertCircle, Loader2, ChevronDown } from 'lucide-react'; import { DOMAIN_FRIENDLY, DOMAIN_COLORS, TimeRange, Granularity, URTDomain, } from './types'; // ==================== Types ==================== interface TrendDataPoint { date: string; count: number; positive: number; negative: number; review_count: number; sentiment_score: number; // -100 to +100 // Rating impact - THE BUSINESS VALUE avg_rating_negative: number | null; // Avg stars when complaints mention this category avg_rating_positive: number | null; // Avg stars when praise mentions this category } interface TrendItem { id: string; label: string; color: string; data: TrendDataPoint[]; } interface ExplorerViewProps { jobId?: string; businessId?: string; } // ==================== Constants ==================== const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string }[] = [ { value: '7d', label: '7D', description: 'Last 7 days' }, { value: '14d', label: '2W', description: 'Last 2 weeks' }, { value: '30d', label: '1M', description: 'Last month' }, { value: '90d', label: '3M', description: 'Last 3 months' }, { value: '1y', label: '1Y', description: 'Last year' }, { value: 'all', label: 'All', description: 'All time' }, ]; const DOMAIN_OPTIONS: { value: URTDomain; label: string; emoji: string; color: string }[] = [ { value: 'P', label: DOMAIN_FRIENDLY['P'].label, emoji: DOMAIN_FRIENDLY['P'].emoji, color: DOMAIN_COLORS['P'] }, { value: 'V', label: DOMAIN_FRIENDLY['V'].label, emoji: DOMAIN_FRIENDLY['V'].emoji, color: DOMAIN_COLORS['V'] }, { value: 'J', label: DOMAIN_FRIENDLY['J'].label, emoji: DOMAIN_FRIENDLY['J'].emoji, color: DOMAIN_COLORS['J'] }, { value: 'O', label: DOMAIN_FRIENDLY['O'].label, emoji: DOMAIN_FRIENDLY['O'].emoji, color: DOMAIN_COLORS['O'] }, { value: 'A', label: DOMAIN_FRIENDLY['A'].label, emoji: DOMAIN_FRIENDLY['A'].emoji, color: DOMAIN_COLORS['A'] }, { value: 'E', label: DOMAIN_FRIENDLY['E'].label, emoji: DOMAIN_FRIENDLY['E'].emoji, color: DOMAIN_COLORS['E'] }, { value: 'R', label: DOMAIN_FRIENDLY['R'].label, emoji: DOMAIN_FRIENDLY['R'].emoji, color: DOMAIN_COLORS['R'] }, ]; // Metric options for dropdown type MetricType = 'damage' | 'sentiment'; const METRIC_OPTIONS: { value: MetricType; label: string; description: string }[] = [ { value: 'damage', label: '📉 Reputation Damage', description: 'Cumulative star loss from complaints' }, { value: 'sentiment', label: '📊 Net Mentions', description: 'Positive minus negative (cumulative)' }, ]; // Map time range to granularity const getGranularity = (timeRange: TimeRange): Granularity => { switch (timeRange) { case '7d': case '14d': return 'day'; case '30d': return 'week'; case '90d': return 'week'; case '1y': return 'month'; case 'all': return 'month'; default: return 'week'; } }; // ==================== Component ==================== const MAX_CATEGORIES = 4; export function ExplorerView({ jobId, businessId }: ExplorerViewProps) { // State const [selectedCategories, setSelectedCategories] = useState(['P', 'V']); const [selectedMetric, setSelectedMetric] = useState('damage'); const [timeRange, setTimeRange] = useState('1y'); const [trendData, setTrendData] = useState([]); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [isMetricOpen, setIsMetricOpen] = useState(false); // Fetch trend data when selection or time range changes useEffect(() => { if (selectedCategories.length === 0) { setTrendData([]); return; } const fetchTrendData = async () => { setIsLoading(true); setError(null); try { const granularity = getGranularity(timeRange); const itemsParam = selectedCategories.join(','); const url = `/api/pipelines/reviewiq/trends?job_id=${jobId}&items=${itemsParam}&time_range=${timeRange}&granularity=${granularity}`; const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch trend data: ${response.statusText}`); } const data: TrendItem[] = await response.json(); setTrendData(data); } catch (err) { console.error('Error fetching trend data:', err); setError(err instanceof Error ? err.message : 'Failed to load trend data'); setTrendData([]); } finally { setIsLoading(false); } }; fetchTrendData(); }, [jobId, selectedCategories, timeRange]); // Transform data for Recharts - calculate cumulative damage AND normalized sentiment const chartData = useMemo(() => { if (trendData.length === 0) return []; // Get all unique dates const dateSet = new Set(); trendData.forEach((item) => { item.data.forEach((d) => dateSet.add(d.date)); }); const dates = Array.from(dateSet).sort( (a, b) => new Date(a).getTime() - new Date(b).getTime() ); // Track cumulative values per category const cumulatives: Record = {}; trendData.forEach((item) => { cumulatives[item.id] = { damage: 0, totalPositive: 0, totalNegative: 0, totalCount: 0 }; }); // Build chart data with cumulative values for each category return dates.map((date) => { const point: Record = { date }; trendData.forEach((item) => { const dataPoint = item.data.find((d) => d.date === date); if (dataPoint) { // Damage: complaints * (5 - avg_rating) const avgRating = dataPoint.avg_rating_negative ?? 3; const periodDamage = dataPoint.negative * (5 - avgRating); cumulatives[item.id].damage += periodDamage; // Track totals for normalized sentiment cumulatives[item.id].totalPositive += dataPoint.positive; cumulatives[item.id].totalNegative += dataPoint.negative; cumulatives[item.id].totalCount += dataPoint.count; } // Damage as negative (going down like losses) point[`${item.id}_damage`] = -Math.round(cumulatives[item.id].damage * 10) / 10; // Net Mentions: cumulative (positive - negative) - preserves volume! const { totalPositive, totalNegative } = cumulatives[item.id]; point[`${item.id}_sentiment`] = totalPositive - totalNegative; // Store period data for tooltips const dp = item.data.find((d) => d.date === date); point[`${item.id}_periodDamage`] = dp ? Math.round((dp.negative * (5 - (dp.avg_rating_negative ?? 3))) * 10) / 10 : 0; point[`${item.id}_complaints`] = dp?.negative ?? 0; point[`${item.id}_avgRating`] = dp?.avg_rating_negative ?? null; point[`${item.id}_periodSentiment`] = dp?.sentiment_score ?? 0; }); return point; }); }, [trendData]); // Toggle category selection const toggleCategory = (category: URTDomain) => { setSelectedCategories((prev) => { if (prev.includes(category)) { return prev.filter((c) => c !== category); } if (prev.length >= MAX_CATEGORIES) { return [...prev.slice(1), category]; } return [...prev, category]; }); }; // Format date for display const formatDate = (dateStr: string) => { const date = new Date(dateStr); const granularity = getGranularity(timeRange); switch (granularity) { case 'day': return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); case 'week': return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); case 'month': return date.toLocaleDateString('en-US', { month: 'short', year: '2-digit' }); case 'year': return date.toLocaleDateString('en-US', { year: 'numeric' }); default: return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } }; const currentMetric = METRIC_OPTIONS.find(o => o.value === selectedMetric); return (
{/* Header */}

Reputation Tracker

Compare cumulative damage across categories

{/* Time Range Selector */}
{TIME_RANGE_OPTIONS.map((opt) => ( ))}
{/* Category Chips + Metric Dropdown */}
{/* Category Toggle Chips */}
{DOMAIN_OPTIONS.map((opt) => { const isSelected = selectedCategories.includes(opt.value); return ( ); })}
{/* Metric Dropdown */}
Show:
{isMetricOpen && ( <>
setIsMetricOpen(false)} />
{METRIC_OPTIONS.map((opt) => ( ))}
)}
Select up to {MAX_CATEGORIES} categories to compare
{/* Chart Area */}
{/* Loading State */} {isLoading && (
Loading data...
)} {/* Error State */} {error && !isLoading && (

Failed to load data

{error}

)} {/* Empty State - No categories selected */} {!isLoading && !error && selectedCategories.length === 0 && (

No categories selected

Click categories above to compare their reputation impact

)} {/* No Data State */} {!isLoading && !error && selectedCategories.length > 0 && chartData.length === 0 && (

No data available

Try selecting a different time range

)} {/* Chart */} {!isLoading && !error && chartData.length > 0 && ( {/* Generate gradients for each selected category */} {trendData.map((item) => ( ))} value.toFixed(0)} /> {/* Center reference line at 0 */} { if (active && payload && payload.length) { const data = payload[0].payload; return (

{formatDate(String(label))}

{trendData.map((item) => { const friendly = DOMAIN_FRIENDLY[item.id]; const value = data[`${item.id}_${selectedMetric}`]; const periodDamage = data[`${item.id}_periodDamage`]; const complaints = data[`${item.id}_complaints`]; return (
{friendly?.emoji} {friendly?.label}
{typeof value === 'number' ? value.toFixed(0) : '-'}
Period: -{periodDamage} pts Complaints: {complaints}
); })}
); } return null; }} /> {/* Render area for each selected category */} {trendData.map((item) => ( ))}
)}
{/* Footer */}

{selectedMetric === 'damage' ? 'Each complaint costs points based on how low the rating is. Steeper drops = worse periods.' : 'Cumulative positive minus negative mentions. Above 0 = net positive. Categories with more volume show bigger swings.' }

); }