Initial commit - WhyRating Engine (Google Reviews Scraper)
This commit is contained in:
511
web/components/reviewiq/ExplorerView.tsx
Normal file
511
web/components/reviewiq/ExplorerView.tsx
Normal file
@@ -0,0 +1,511 @@
|
||||
'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<URTDomain[]>(['P', 'V']);
|
||||
const [selectedMetric, setSelectedMetric] = useState<MetricType>('damage');
|
||||
const [timeRange, setTimeRange] = useState<TimeRange>('1y');
|
||||
const [trendData, setTrendData] = useState<TrendItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string>();
|
||||
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<string, {
|
||||
damage: number;
|
||||
totalPositive: number;
|
||||
totalNegative: number;
|
||||
totalCount: number;
|
||||
}> = {};
|
||||
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<string, string | number | null> = { 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 (
|
||||
<div className="bg-white rounded-xl p-6 shadow-md border-2 border-gray-200 hover:border-red-300 transition-all">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 bg-red-100 rounded-lg">
|
||||
<TrendingDown className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-900">Reputation Tracker</h3>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Compare cumulative damage across categories
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Range Selector */}
|
||||
<div className="flex items-center bg-gray-100 rounded-lg p-1">
|
||||
{TIME_RANGE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTimeRange(opt.value)}
|
||||
className={`px-2.5 py-1.5 text-xs font-semibold rounded-md transition-all ${
|
||||
timeRange === opt.value
|
||||
? 'bg-red-600 text-white shadow-sm'
|
||||
: 'text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
title={opt.description}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Chips + Metric Dropdown */}
|
||||
<div className="mb-6 space-y-3">
|
||||
{/* Category Toggle Chips */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{DOMAIN_OPTIONS.map((opt) => {
|
||||
const isSelected = selectedCategories.includes(opt.value);
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => toggleCategory(opt.value)}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium
|
||||
transition-all border-2
|
||||
${isSelected
|
||||
? 'shadow-md'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
style={isSelected ? {
|
||||
backgroundColor: `${opt.color}15`,
|
||||
borderColor: opt.color,
|
||||
color: opt.color,
|
||||
} : undefined}
|
||||
>
|
||||
<span className="text-base">{opt.emoji}</span>
|
||||
<span>{opt.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Metric Dropdown */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-500">Show:</span>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsMetricOpen(!isMetricOpen)}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-gray-50 border border-gray-200 rounded-lg hover:border-gray-300 transition-all text-sm"
|
||||
>
|
||||
<span className="font-medium text-gray-700">{currentMetric?.label}</span>
|
||||
<ChevronDown className={`w-4 h-4 text-gray-400 transition-transform ${isMetricOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{isMetricOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setIsMetricOpen(false)} />
|
||||
<div className="absolute top-full left-0 mt-1 z-20 bg-white rounded-xl shadow-xl border border-gray-200 py-1 min-w-[250px]">
|
||||
{METRIC_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => { setSelectedMetric(opt.value); setIsMetricOpen(false); }}
|
||||
className={`w-full flex flex-col items-start px-4 py-2.5 hover:bg-gray-50 transition-colors ${
|
||||
selectedMetric === opt.value ? 'bg-red-50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="font-medium text-gray-700">{opt.label}</span>
|
||||
<span className="text-xs text-gray-500">{opt.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-gray-400 ml-auto">
|
||||
Select up to {MAX_CATEGORIES} categories to compare
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chart Area */}
|
||||
<div className="relative">
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-white/80 z-10 flex items-center justify-center rounded-lg">
|
||||
<div className="flex items-center gap-3 text-red-600">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span className="font-medium">Loading data...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-red-500">
|
||||
<AlertCircle className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">Failed to load data</p>
|
||||
<p className="text-sm text-gray-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State - No categories selected */}
|
||||
{!isLoading && !error && selectedCategories.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
|
||||
<TrendingDown className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">No categories selected</p>
|
||||
<p className="text-sm">Click categories above to compare their reputation impact</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Data State */}
|
||||
{!isLoading && !error && selectedCategories.length > 0 && chartData.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-400">
|
||||
<TrendingDown className="w-12 h-12 mb-3" />
|
||||
<p className="font-medium mb-1">No data available</p>
|
||||
<p className="text-sm">Try selecting a different time range</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chart */}
|
||||
{!isLoading && !error && chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<AreaChart
|
||||
data={chartData}
|
||||
margin={{ top: 10, right: 30, left: 0, bottom: 10 }}
|
||||
>
|
||||
<defs>
|
||||
{/* Generate gradients for each selected category */}
|
||||
{trendData.map((item) => (
|
||||
<linearGradient key={`gradient-${item.id}`} id={`gradient-${item.id}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={item.color} stopOpacity={0.3}/>
|
||||
<stop offset="95%" stopColor={item.color} stopOpacity={0.05}/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" vertical={false} />
|
||||
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#6b7280', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
tickFormatter={formatDate}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
|
||||
<YAxis
|
||||
tick={{ fill: '#6b7280', fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => value.toFixed(0)}
|
||||
/>
|
||||
|
||||
{/* Center reference line at 0 */}
|
||||
<ReferenceLine y={0} stroke="#9ca3af" strokeDasharray="5 5" strokeWidth={1.5} />
|
||||
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 10px 40px rgba(0,0,0,0.15)',
|
||||
padding: '16px',
|
||||
}}
|
||||
content={({ active, payload, label }) => {
|
||||
if (active && payload && payload.length) {
|
||||
const data = payload[0].payload;
|
||||
return (
|
||||
<div className="min-w-[240px]">
|
||||
<p className="font-bold text-gray-900 mb-3 pb-2 border-b border-gray-100">
|
||||
{formatDate(String(label))}
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
{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 (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded-lg px-3 py-2 -mx-1"
|
||||
style={{ backgroundColor: `${item.color}10` }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{friendly?.emoji}</span>
|
||||
<span className="text-sm font-semibold" style={{ color: item.color }}>
|
||||
{friendly?.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="font-bold" style={{ color: item.color }}>
|
||||
{typeof value === 'number' ? value.toFixed(0) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex gap-3">
|
||||
<span>Period: -{periodDamage} pts</span>
|
||||
<span>Complaints: {complaints}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Render area for each selected category */}
|
||||
{trendData.map((item) => (
|
||||
<Area
|
||||
key={item.id}
|
||||
type="monotone"
|
||||
dataKey={`${item.id}_${selectedMetric}`}
|
||||
name={item.label}
|
||||
stroke={item.color}
|
||||
strokeWidth={2.5}
|
||||
fill={`url(#gradient-${item.id})`}
|
||||
fillOpacity={0.6}
|
||||
dot={false}
|
||||
activeDot={{ r: 5, strokeWidth: 2, stroke: '#fff', fill: item.color }}
|
||||
/>
|
||||
))}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||
<p className="text-xs text-gray-500 text-center">
|
||||
{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.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,16 +15,16 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { X, TrendingUp, TrendingDown, Minus, Calendar, Filter } from 'lucide-react';
|
||||
import type { TimelinePoint, TimeRange, TimelineAnnotation } from '../types';
|
||||
import type { TimelinePoint, TimeRange, Granularity } from '../types';
|
||||
import { DOMAIN_LABELS } from '../types';
|
||||
import { useReviewIQFilters } from '@/contexts/ReviewIQFilterContext';
|
||||
|
||||
interface TimelineChartProps {
|
||||
data: TimelinePoint[];
|
||||
// AI-generated insight (optional - shows when available)
|
||||
// AI-generated insight headline (optional - shows when available)
|
||||
insight?: string | null;
|
||||
// Timeline annotations from AI (optional - marks key events)
|
||||
annotations?: TimelineAnnotation[] | null;
|
||||
// Timeline granularity from API (day, week, month, year)
|
||||
granularity?: Granularity;
|
||||
}
|
||||
|
||||
type ViewMode = 'sentiment' | 'volume' | 'rating';
|
||||
@@ -49,7 +49,7 @@ const TIME_RANGE_OPTIONS: { value: TimeRange; label: string; description: string
|
||||
* User-friendly design with view toggles and interactive brush.
|
||||
* Responds to domain/sentiment filters.
|
||||
*/
|
||||
export function TimelineChart({ data, insight, annotations }: TimelineChartProps) {
|
||||
export function TimelineChart({ data, insight, granularity = 'week' }: TimelineChartProps) {
|
||||
const { filters, setTimeRange, setBrushRange } = useReviewIQFilters();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('sentiment');
|
||||
const [localBrushRange, setLocalBrushRange] = useState<{
|
||||
@@ -137,10 +137,21 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
|
||||
const hasSentimentFilter = filters.sentiment.length > 0;
|
||||
const hasAnyFilter = hasBrushFilter || hasDomainFilter || hasSentimentFilter;
|
||||
|
||||
// Format date for display
|
||||
// Format date for display based on granularity
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
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' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -281,31 +292,6 @@ export function TimelineChart({ data, insight, annotations }: TimelineChartProps
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key Events (when annotations available) */}
|
||||
{annotations && annotations.length > 0 && (
|
||||
<div className="mb-4 flex flex-wrap gap-2">
|
||||
{annotations.slice(0, 3).map((annotation, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`px-3 py-1.5 rounded-full text-xs font-medium flex items-center gap-1 ${
|
||||
annotation.type === 'positive' ? 'bg-green-100 text-green-700' :
|
||||
annotation.type === 'negative' ? 'bg-red-100 text-red-700' :
|
||||
annotation.type === 'event' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
title={annotation.description}
|
||||
>
|
||||
<span>{
|
||||
annotation.type === 'positive' ? '📈' :
|
||||
annotation.type === 'negative' ? '📉' :
|
||||
annotation.type === 'event' ? '📍' : '•'
|
||||
}</span>
|
||||
<span>{annotation.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedData.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-80 text-gray-500">
|
||||
<Calendar className="w-12 h-12 text-gray-300 mb-2" />
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
|
||||
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
@@ -25,8 +25,6 @@ interface ExecutiveSummaryProps {
|
||||
domainScores?: DomainScore[];
|
||||
onDriverClick?: (subcode: string) => void;
|
||||
onDomainClick?: (domain: URTDomain) => void;
|
||||
// AI-generated narrative (optional - enhances when available)
|
||||
synthesis?: Synthesis | null;
|
||||
}
|
||||
|
||||
// User-friendly domain config
|
||||
@@ -204,13 +202,12 @@ export function ExecutiveSummary({
|
||||
domainScores,
|
||||
onDriverClick,
|
||||
onDomainClick,
|
||||
synthesis,
|
||||
}: ExecutiveSummaryProps) {
|
||||
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
||||
const [showFullSummary, setShowFullSummary] = useState(false);
|
||||
|
||||
// Use AI narrative if available, otherwise fall back to generated summary
|
||||
const narrativeText = synthesis?.executive_narrative || executive_summary;
|
||||
// Use the generated summary from insights
|
||||
const narrativeText = executive_summary;
|
||||
|
||||
const topStrength = strengths[0];
|
||||
const topWeakness = weaknesses[0];
|
||||
@@ -294,20 +291,13 @@ export function ExecutiveSummary({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{/* Summary */}
|
||||
{narrativeText && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
synthesis?.executive_narrative
|
||||
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
|
||||
: 'bg-white/70 border-blue-100'
|
||||
}`}>
|
||||
<div className="p-4 rounded-xl border bg-white/70 border-blue-100">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
||||
<span className="text-lg">💡</span>
|
||||
<div className="flex-1">
|
||||
{synthesis?.executive_narrative && (
|
||||
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
|
||||
)}
|
||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
|
||||
{narrativeText}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user