Files
whyrating-engine-legacy/web/components/reviewiq/insights/OpportunityMatrix.tsx
Alejandro Gutiérrez c8ecb4b98f feat(reviewiq): Add AI synthesis support to dashboard components
Frontend:
- Add Synthesis type with action plan, insights, annotations
- ExecutiveSummary: Accept synthesis prop for AI narrative
- SentimentPie: Accept insight prop for contextual explanation
- IntensityHeatmap: Accept insight + highlightDomain props
- TimelineChart: Accept insight + annotations props
- All components gracefully degrade when synthesis is null

Backend:
- Add Stage 4: Synthesize for generating AI narratives
- Gathers context from classified spans
- Generates executive narrative, section insights, action plan
- Produces timeline annotations and marketing angles
- Stores synthesis in pipeline.executions table

Components show AI insights with purple gradient styling when available,
fall back to existing behavior when synthesis is not yet generated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 02:59:47 +00:00

1203 lines
40 KiB
TypeScript

'use client';
import { useMemo, useState, useRef, useEffect } from 'react';
import { Target, Zap, Clock, LayoutGrid, TrendingUp, Users, Wrench, X, ChevronRight, MessageSquare, Star, Maximize2 } from 'lucide-react';
import type { OpportunityMatrix as OpportunityMatrixType, OpportunityItem, OpportunitySpan } from '../types';
import { ReviewModal } from '../tables/ReviewModal';
import { DOMAIN_COLORS, COMPLEXITY_LABELS } from '../types';
interface OpportunityMatrixProps {
matrix: OpportunityMatrixType | null;
onSubcodeClick?: (subcode: string) => void;
}
interface QuadrantProps {
title: string;
icon: React.ReactNode;
items: OpportunityItem[];
bgColor: string;
borderColor: string;
textColor: string;
iconBg: string;
onItemClick?: (item: OpportunityItem) => void;
onItemHover?: (item: OpportunityItem | null) => void;
selectedItem?: OpportunityItem | null;
}
type ConnectorSide = 'top' | 'bottom' | 'left' | 'right';
interface LabelPosition {
x: number; // Label center X
y: number; // Label center Y
connector: ConnectorSide; // Which side the edge connects to
nodeX: number;
nodeY: number;
}
// Get the connector point on the label edge based on connector side
function getConnectorPoint(pos: LabelPosition): { x: number; y: number } {
const halfW = LABEL_WIDTH / 2;
const halfH = LABEL_HEIGHT / 2;
switch (pos.connector) {
case 'top':
return { x: pos.x, y: pos.y - halfH };
case 'bottom':
return { x: pos.x, y: pos.y + halfH };
case 'left':
return { x: pos.x - halfW, y: pos.y };
case 'right':
return { x: pos.x + halfW, y: pos.y };
}
}
// Determine the best connector side based on angle from node to label
function getConnectorSide(nodeX: number, nodeY: number, labelX: number, labelY: number): ConnectorSide {
const angle = Math.atan2(labelY - nodeY, labelX - nodeX) * (180 / Math.PI);
// Angle ranges for each connector:
// Right: -45 to 45 (label is to the right of node, connect on left side of label)
// Bottom: 45 to 135 (label is below node, connect on top of label)
// Left: 135 to 180 or -180 to -135 (label is to the left, connect on right side)
// Top: -135 to -45 (label is above node, connect on bottom of label)
if (angle >= -45 && angle < 45) {
return 'left'; // Label is to the right, connect on its left side
} else if (angle >= 45 && angle < 135) {
return 'top'; // Label is below, connect on its top
} else if (angle >= -135 && angle < -45) {
return 'bottom'; // Label is above, connect on its bottom
} else {
return 'right'; // Label is to the left, connect on its right side
}
}
// Estimated label dimensions in percentage units
const LABEL_WIDTH = 18; // ~18% width for truncated names (up to 14 chars)
const LABEL_HEIGHT = 6; // ~6% height
// Check if two rectangles overlap (with padding)
function rectsOverlap(
r1: { x: number; y: number; w: number; h: number },
r2: { x: number; y: number; w: number; h: number },
padding = 1
): boolean {
return !(
r1.x + r1.w + padding < r2.x ||
r2.x + r2.w + padding < r1.x ||
r1.y + r1.h + padding < r2.y ||
r2.y + r2.h + padding < r1.y
);
}
// Get label bounding box based on center position
function getLabelRect(pos: { x: number; y: number }) {
const w = LABEL_WIDTH;
const h = LABEL_HEIGHT;
const x = pos.x - w / 2;
const y = pos.y - h / 2;
return { x, y, w, h };
}
// Check if two line segments intersect
function linesIntersect(
p1: { x: number; y: number },
p2: { x: number; y: number },
p3: { x: number; y: number },
p4: { x: number; y: number }
): boolean {
const ccw = (A: { x: number; y: number }, B: { x: number; y: number }, C: { x: number; y: number }) => {
return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x);
};
// Check if segments share an endpoint (not a real crossing)
const samePoint = (a: { x: number; y: number }, b: { x: number; y: number }) =>
Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5;
if (samePoint(p1, p3) || samePoint(p1, p4) || samePoint(p2, p3) || samePoint(p2, p4)) {
return false;
}
return ccw(p1, p3, p4) !== ccw(p2, p3, p4) && ccw(p1, p2, p3) !== ccw(p1, p2, p4);
}
interface Cluster {
centerX: number;
centerY: number;
radius: number;
nodeIndices: number[];
}
// Find clusters of nearby nodes using distance threshold
function findClusters(nodes: Array<{ x: number; y: number }>, threshold: number): Cluster[] {
const visited = new Set<number>();
const clusters: Cluster[] = [];
for (let i = 0; i < nodes.length; i++) {
if (visited.has(i)) continue;
// BFS to find all connected nodes
const clusterIndices: number[] = [];
const queue = [i];
while (queue.length > 0) {
const current = queue.shift()!;
if (visited.has(current)) continue;
visited.add(current);
clusterIndices.push(current);
// Find neighbors within threshold
for (let j = 0; j < nodes.length; j++) {
if (visited.has(j)) continue;
const dist = Math.hypot(nodes[j].x - nodes[current].x, nodes[j].y - nodes[current].y);
if (dist < threshold) {
queue.push(j);
}
}
}
// Calculate cluster center and radius
if (clusterIndices.length > 0) {
const clusterNodes = clusterIndices.map((idx) => nodes[idx]);
const centerX = clusterNodes.reduce((sum, n) => sum + n.x, 0) / clusterNodes.length;
const centerY = clusterNodes.reduce((sum, n) => sum + n.y, 0) / clusterNodes.length;
// Radius is max distance from center + padding for labels
let maxDist = 0;
for (const n of clusterNodes) {
const dist = Math.hypot(n.x - centerX, n.y - centerY);
maxDist = Math.max(maxDist, dist);
}
const radius = maxDist + 15; // Padding for labels
clusters.push({ centerX, centerY, radius, nodeIndices: clusterIndices });
}
}
return clusters;
}
// Generate candidate positions for a node based on cluster membership
function generateCandidatePositions(
node: { x: number; y: number },
cluster: Cluster | null
): Array<{ x: number; y: number }> {
const positions: Array<{ x: number; y: number }> = [];
if (cluster && cluster.nodeIndices.length > 1) {
// For clustered nodes: project outward from cluster center through the node
const baseAngle = Math.atan2(node.y - cluster.centerY, node.x - cluster.centerX);
// Primary position: on the cluster circle boundary
// Plus variations around that angle for fine-tuning
const angleVariations = [0, -0.25, 0.25, -0.5, 0.5, -0.75, 0.75];
const distanceVariations = [cluster.radius, cluster.radius + 6, cluster.radius - 4];
for (const distVar of distanceVariations) {
for (const angleVar of angleVariations) {
const angle = baseAngle + angleVar;
const x = cluster.centerX + Math.cos(angle) * distVar;
const y = cluster.centerY + Math.sin(angle) * distVar * 0.85;
positions.push({ x, y });
}
}
} else {
// For isolated nodes: use clock positions around the node
const distances = [12, 16];
for (const dist of distances) {
for (let hour = 0; hour < 12; hour++) {
const angle = (hour * 30 - 90) * (Math.PI / 180);
const dx = Math.cos(angle) * dist;
const dy = Math.sin(angle) * dist * 0.75;
positions.push({ x: node.x + dx, y: node.y + dy });
}
}
}
return positions;
}
// Calculate optimal label positions to avoid overlaps
function calculateLabelPositions(items: OpportunityItem[]): LabelPosition[] {
if (items.length === 0) return [];
// Convert items to node positions
const nodes = items.map((item) => ({
x: 15 + item.x * 70,
y: 30 + item.y * 55,
subcode: item.subcode,
}));
// Find clusters of nearby nodes
const clusters = findClusters(nodes, 15);
// Map each node to its cluster (if any)
const nodeToCluster = new Map<number, Cluster>();
for (const cluster of clusters) {
if (cluster.nodeIndices.length > 1) {
for (const idx of cluster.nodeIndices) {
nodeToCluster.set(idx, cluster);
}
}
}
// Initialize result array with default positions
const result: LabelPosition[] = nodes.map((node) => ({
x: node.x + 12,
y: node.y,
connector: 'left' as ConnectorSide,
nodeX: node.x,
nodeY: node.y,
}));
// Track which labels have been placed
const placedLabels: LabelPosition[] = [];
// Sort: process clustered nodes first (by cluster size desc), then by angle within cluster
const sortedIndices = nodes
.map((_, i) => i)
.sort((a, b) => {
const clusterA = nodeToCluster.get(a);
const clusterB = nodeToCluster.get(b);
const sizeA = clusterA?.nodeIndices.length ?? 0;
const sizeB = clusterB?.nodeIndices.length ?? 0;
if (sizeA !== sizeB) return sizeB - sizeA; // Larger clusters first
// Within same cluster, sort by angle from center
if (clusterA && clusterA === clusterB) {
const angleA = Math.atan2(nodes[a].y - clusterA.centerY, nodes[a].x - clusterA.centerX);
const angleB = Math.atan2(nodes[b].y - clusterA.centerY, nodes[b].x - clusterA.centerX);
return angleA - angleB;
}
return nodes[a].y - nodes[b].y;
});
for (const idx of sortedIndices) {
const node = nodes[idx];
const cluster = nodeToCluster.get(idx) ?? null;
const candidates = generateCandidatePositions(node, cluster);
let bestPosition: LabelPosition | null = null;
let minScore = Infinity;
for (const candidate of candidates) {
// Determine which connector side based on angle from node to label center
const connector = getConnectorSide(node.x, node.y, candidate.x, candidate.y);
const pos: LabelPosition = {
x: candidate.x,
y: candidate.y,
connector,
nodeX: node.x,
nodeY: node.y,
};
// Check bounds (keep label within quadrant)
const rect = getLabelRect(pos);
if (rect.x < 3 || rect.x + rect.w > 97 || rect.y < 22 || rect.y + rect.h > 93) {
continue;
}
// Calculate score (lower is better)
let score = 0;
// Penalty for overlapping with placed labels
for (const placed of placedLabels) {
const placedRect = getLabelRect(placed);
if (rectsOverlap(rect, placedRect, 2)) {
const overlapX = Math.max(0, Math.min(rect.x + rect.w, placedRect.x + placedRect.w) - Math.max(rect.x, placedRect.x));
const overlapY = Math.max(0, Math.min(rect.y + rect.h, placedRect.y + placedRect.h) - Math.max(rect.y, placedRect.y));
score += (overlapX * overlapY) * 10;
}
}
// Penalty for overlapping with other nodes (dots)
for (const otherNode of nodes) {
if (otherNode === node) continue;
const nodeRect = { x: otherNode.x - 3, y: otherNode.y - 3, w: 6, h: 6 };
if (rectsOverlap(rect, nodeRect, 0)) {
score += 100;
}
}
// Heavy penalty for leader line crossing other leader lines (using connector points)
const connectorPoint = getConnectorPoint(pos);
for (const placed of placedLabels) {
const placedConnector = getConnectorPoint(placed);
if (linesIntersect(
{ x: node.x, y: node.y },
connectorPoint,
{ x: placed.nodeX, y: placed.nodeY },
placedConnector
)) {
score += 200;
}
}
// Small penalty for distance from node (prefer closer positions)
const dist = Math.hypot(candidate.x - node.x, candidate.y - node.y);
score += dist * 0.05;
if (score < minScore) {
minScore = score;
bestPosition = pos;
}
if (score < 0.1) break;
}
if (bestPosition) {
result[idx] = bestPosition;
}
placedLabels.push(result[idx]);
}
return result;
}
function Quadrant({
title,
icon,
items,
bgColor,
borderColor,
textColor,
iconBg,
onItemClick,
onItemHover,
selectedItem,
}: QuadrantProps) {
// Calculate optimized label positions
const labelPositions = useMemo(() => calculateLabelPositions(items), [items]);
return (
<div className={`${bgColor} rounded-lg border ${borderColor} relative overflow-hidden`}>
{/* Header */}
<div className="absolute top-2 left-2 right-2 flex items-center gap-1.5 z-10">
<div className={`p-1 ${iconBg} rounded`}>{icon}</div>
<span className={`text-xs font-bold ${textColor}`}>{title}</span>
<span className="ml-auto text-xs font-semibold text-gray-500 bg-white/60 px-1.5 py-0.5 rounded">
{items.length}
</span>
</div>
{/* Positioned items with dots and leader lines */}
{items.length === 0 ? (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs text-gray-400 italic">None</span>
</div>
) : (
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10">
{items.map((item, index) => {
const pos = labelPositions[index];
if (!pos) return null;
// Get the connector point on the label edge
const connector = getConnectorPoint(pos);
const isSelected = selectedItem?.subcode === item.subcode;
return (
<g key={`${item.subcode}-${index}`}>
{/* Leader line from node to label connector */}
<line
x1={`${pos.nodeX}%`}
y1={`${pos.nodeY}%`}
x2={`${connector.x}%`}
y2={`${connector.y}%`}
stroke="currentColor"
strokeWidth={isSelected ? 2 : 1}
opacity={isSelected ? 0.6 : 0.3}
/>
{/* Dot at node position */}
<circle
cx={`${pos.nodeX}%`}
cy={`${pos.nodeY}%`}
r={isSelected ? 5 : 4}
fill="currentColor"
opacity={isSelected ? 0.8 : 0.6}
/>
</g>
);
})}
</svg>
)}
{/* Clickable labels */}
{items.length > 0 && items.map((item, index) => {
const pos = labelPositions[index];
if (!pos) return null;
// Truncate long names for display
const displayName = item.name.length > 14 ? item.name.slice(0, 12) + '…' : item.name;
const isSelected = selectedItem?.subcode === item.subcode;
return (
<button
key={`label-${item.subcode}-${index}`}
onClick={() => onItemClick?.(item)}
onMouseEnter={() => onItemHover?.(item)}
onMouseLeave={() => onItemHover?.(null)}
className={`absolute px-1.5 py-0.5 text-[10px] font-medium rounded ${textColor} transition-all shadow-sm border z-20 whitespace-nowrap ${
isSelected
? 'bg-white scale-110 ring-2 ring-current/40 border-current/40'
: 'bg-white/95 hover:bg-white hover:scale-105 border-current/20'
}`}
style={{
left: `${pos.x}%`,
top: `${pos.y}%`,
transform: 'translate(-50%, -50%)',
}}
>
{displayName}
</button>
);
})}
</div>
);
}
// Hover preview card
function HoverCard({ item, position }: { item: OpportunityItem; position: { x: number; y: number } }) {
return (
<div
className="fixed z-50 bg-white rounded-lg shadow-xl border border-gray-200 p-3 w-56 pointer-events-none"
style={{
left: position.x + 10,
top: position.y - 10,
transform: 'translateY(-100%)',
}}
>
<div className="flex items-start gap-2 mb-2">
<div
className="w-2 h-2 rounded-full mt-1.5 flex-shrink-0"
style={{ backgroundColor: DOMAIN_COLORS[item.domain] || '#6b7280' }}
/>
<div>
<div className="font-semibold text-sm text-gray-900">{item.name}</div>
<div className="text-xs text-gray-500">{item.domain_name}</div>
</div>
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-gray-500">Negative</span>
<span className="font-medium text-red-600">{item.negative_pct}%</span>
</div>
<div className="w-full bg-gray-100 rounded-full h-1.5">
<div
className="bg-red-500 h-1.5 rounded-full"
style={{ width: `${item.negative_pct}%` }}
/>
</div>
{item.rating_impact && (
<div className="flex justify-between text-xs pt-1">
<span className="text-gray-500">Rating impact</span>
<span className="font-medium text-green-600">+{item.rating_impact}</span>
</div>
)}
</div>
<div className="mt-2 pt-2 border-t border-gray-100 text-xs text-gray-400">
Click for details
</div>
</div>
);
}
// Render stars for rating
function RatingStars({ rating }: { rating: number }) {
return (
<div className="flex items-center gap-0.5">
{[1, 2, 3, 4, 5].map((star) => (
<Star
key={star}
className={`w-3 h-3 ${
star <= rating
? 'text-amber-400 fill-amber-400'
: 'text-gray-300 fill-gray-300'
}`}
/>
))}
</div>
);
}
// Normalize text for matching (handles spacing differences around punctuation)
function normalizeForMatching(text: string): string {
return text
.toLowerCase()
.replace(/\s+([.,!?;:])/g, '$1') // Remove space before punctuation
.replace(/([.,!?;:])\s+/g, '$1 ') // Normalize space after punctuation
.replace(/\s+/g, ' ') // Multiple spaces to single
.trim();
}
// Find span in review using normalized matching, return original indices
function findSpanInReview(reviewText: string, spanText: string): { start: number; end: number } | null {
// Try exact match first (case-insensitive)
const lowerReview = reviewText.toLowerCase();
const lowerSpan = spanText.toLowerCase();
let idx = lowerReview.indexOf(lowerSpan);
if (idx !== -1) {
return { start: idx, end: idx + spanText.length };
}
// Try normalized matching
const normReview = normalizeForMatching(reviewText);
const normSpan = normalizeForMatching(spanText);
const normIdx = normReview.indexOf(normSpan);
if (normIdx === -1) return null;
// Map normalized index back to original text
// Walk through original text, tracking normalized position
let origIdx = 0;
let normPos = 0;
const targetNormStart = normIdx;
const targetNormEnd = normIdx + normSpan.length;
let origStart = -1;
let origEnd = -1;
while (origIdx <= reviewText.length && normPos <= normReview.length) {
if (normPos === targetNormStart) origStart = origIdx;
if (normPos === targetNormEnd) {
origEnd = origIdx;
break;
}
if (origIdx < reviewText.length) {
const origChar = reviewText[origIdx];
const normChar = normReview[normPos];
// Skip characters that were removed in normalization
if (origChar.toLowerCase() === normChar) {
origIdx++;
normPos++;
} else if (/\s/.test(origChar)) {
origIdx++; // Skip extra whitespace in original
} else {
origIdx++;
normPos++;
}
} else {
break;
}
}
if (origStart !== -1 && origEnd === -1) {
origEnd = reviewText.length;
}
return origStart !== -1 ? { start: origStart, end: origEnd } : null;
}
// Highlight span text within review text
function HighlightedReviewText({
reviewText,
spanText,
maxLength = 150,
expanded = false,
}: {
reviewText: string;
spanText: string;
maxLength?: number;
expanded?: boolean;
}) {
// Find the span within the review using fuzzy matching
const match = findSpanInReview(reviewText, spanText);
if (!match) {
// Span not found, just show truncated review
const displayText = expanded ? reviewText : reviewText.slice(0, maxLength);
return (
<span>
{displayText}
{!expanded && reviewText.length > maxLength && '...'}
</span>
);
}
const spanIndex = match.start;
const spanEnd = match.end;
// If span covers most of the review (>80%), highlight the whole thing
const spanCoverage = (spanEnd - spanIndex) / reviewText.length;
if (spanCoverage > 0.8) {
const displayText = expanded ? reviewText : reviewText.slice(0, maxLength);
return (
<mark className="bg-yellow-200 text-gray-900 px-0.5 rounded">
{displayText}
{!expanded && reviewText.length > maxLength && '...'}
</mark>
);
}
// Calculate display window around the span
let displayStart = 0;
let displayEnd = reviewText.length;
if (!expanded) {
// Show context around the span
const contextBefore = 40;
const contextAfter = 60;
displayStart = Math.max(0, spanIndex - contextBefore);
displayEnd = Math.min(reviewText.length, spanEnd + contextAfter);
// Adjust to not cut words
if (displayStart > 0) {
const spaceIndex = reviewText.indexOf(' ', displayStart);
if (spaceIndex !== -1 && spaceIndex < spanIndex) {
displayStart = spaceIndex + 1;
}
}
if (displayEnd < reviewText.length) {
const spaceIndex = reviewText.lastIndexOf(' ', displayEnd);
if (spaceIndex !== -1 && spaceIndex > spanEnd) {
displayEnd = spaceIndex;
}
}
}
const before = reviewText.slice(displayStart, spanIndex);
const highlight = reviewText.slice(spanIndex, spanEnd);
const after = reviewText.slice(spanEnd, displayEnd);
return (
<span>
{displayStart > 0 && '...'}
{before}
<mark className="bg-yellow-200 text-gray-900 px-0.5 rounded">{highlight}</mark>
{after}
{!expanded && displayEnd < reviewText.length && '...'}
</span>
);
}
// Google-style review card for customer feedback
function SpanCard({
span,
onViewReview
}: {
span: OpportunitySpan;
onViewReview?: (reviewId: string, spanId: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const reviewText = span.review_text || span.span_text;
const isLongReview = reviewText.length > 150;
return (
<div className="bg-white rounded-lg p-3 border border-gray-200 hover:shadow-sm transition-shadow group">
{/* Header: Stars + Date + Expand button */}
<div className="flex items-center gap-2 mb-2">
{span.rating && <RatingStars rating={span.rating} />}
{span.review_date && (
<span className="text-xs text-gray-500">{span.review_date}</span>
)}
{span.review_id && onViewReview && (
<button
onClick={() => onViewReview(span.review_id!, span.span_id)}
className="ml-auto p-1 rounded hover:bg-gray-100 opacity-0 group-hover:opacity-100 transition-opacity"
title="View full review"
>
<Maximize2 className="w-3.5 h-3.5 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* Review text with highlighted span */}
<p className="text-sm text-gray-700 leading-relaxed">
{span.review_text ? (
<HighlightedReviewText
reviewText={span.review_text}
spanText={span.span_text}
expanded={expanded}
/>
) : (
<span>"{span.span_text}"</span>
)}
{isLongReview && !expanded && (
<button
onClick={() => setExpanded(true)}
className="ml-1 text-blue-600 hover:text-blue-800 font-medium"
>
More
</button>
)}
{expanded && isLongReview && (
<button
onClick={() => setExpanded(false)}
className="ml-1 text-blue-600 hover:text-blue-800 font-medium"
>
Less
</button>
)}
</p>
</div>
);
}
// Detail panel component
function DetailPanel({
item,
onClose,
onViewReview,
}: {
item: OpportunityItem;
onClose: () => void;
onViewReview?: (reviewId: string, spanId: string) => void;
}) {
return (
<div className="bg-white rounded-xl border-2 border-gray-200 shadow-md p-4 h-full flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-start justify-between mb-4 flex-shrink-0">
<div className="flex items-start gap-3">
<div
className="w-3 h-3 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: DOMAIN_COLORS[item.domain] || '#6b7280' }}
/>
<div>
<h3 className="font-bold text-gray-900">{item.name}</h3>
<div className="text-sm text-gray-500">{item.domain_name} · {item.subcode}</div>
</div>
</div>
<button
onClick={onClose}
className="p-1 hover:bg-gray-100 rounded transition-colors"
>
<X className="w-4 h-4 text-gray-400" />
</button>
</div>
{/* Metrics */}
<div className="grid grid-cols-2 gap-3 mb-4 flex-shrink-0">
<div className="bg-red-50 rounded-lg p-3">
<div className="text-xs text-red-600 mb-1">Negative Sentiment</div>
<div className="text-2xl font-bold text-red-700">{item.negative_pct}%</div>
<div className="text-xs text-red-500">{item.span_count} mentions</div>
</div>
{item.rating_impact && (
<div className="bg-green-50 rounded-lg p-3">
<div className="text-xs text-green-600 mb-1 flex items-center gap-1">
<TrendingUp className="w-3 h-3" />
If Fixed
</div>
<div className="text-2xl font-bold text-green-700">+{item.rating_impact}</div>
<div className="text-xs text-green-500">rating points</div>
</div>
)}
</div>
{/* Complexity */}
<div className="flex items-center gap-2 mb-4 p-2 bg-gray-50 rounded-lg flex-shrink-0">
<Wrench className="w-4 h-4 text-gray-400" />
<span className="text-sm text-gray-600">Complexity:</span>
<span className={`text-sm font-medium px-2 py-0.5 rounded ${
item.complexity === 'simple' ? 'bg-green-100 text-green-700' :
item.complexity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{COMPLEXITY_LABELS[item.complexity] || item.complexity}
</span>
</div>
{/* Customer Feedback Spans (evidence of the problem) - expands to fill available space */}
{item.spans && item.spans.length > 0 && (
<div className="mb-4 flex-1 min-h-0 flex flex-col">
<div className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide flex items-center gap-1.5 flex-shrink-0">
<MessageSquare className="w-3 h-3" />
Customer Feedback ({item.spans.length})
</div>
<div className="overflow-y-auto space-y-2 pr-1 flex-1 min-h-[100px]">
{item.spans.map((span) => (
<SpanCard
key={span.span_id}
span={span}
onViewReview={onViewReview}
/>
))}
</div>
</div>
)}
{/* Solution */}
{item.solution && (
<div className="mb-4 flex-shrink-0">
<div className="text-xs font-medium text-gray-500 mb-2 uppercase tracking-wide">
💡 Suggested Solution
</div>
<p className="text-sm text-gray-700 leading-relaxed bg-blue-50 p-3 rounded-lg border border-blue-100">
{item.solution}
</p>
</div>
)}
{/* Owner */}
{item.owner && (
<div className="flex items-center gap-2 p-2 bg-purple-50 rounded-lg mb-4 flex-shrink-0">
<Users className="w-4 h-4 text-purple-500" />
<span className="text-sm text-purple-700">
<span className="text-purple-500">Assign to:</span> {item.owner}
</span>
</div>
)}
{/* CTA */}
<button className="w-full flex items-center justify-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white py-2 px-4 rounded-lg transition-colors text-sm font-medium flex-shrink-0">
View Related Reviews
<ChevronRight className="w-4 h-4" />
</button>
</div>
);
}
/**
* 2x2 Opportunity Matrix visualization.
* Shows issues categorized by frequency vs complexity with coordinate positioning.
*/
export function OpportunityMatrix({ matrix, onSubcodeClick }: OpportunityMatrixProps) {
const [selectedItem, setSelectedItem] = useState<OpportunityItem | null>(null);
const [hoveredItem, setHoveredItem] = useState<OpportunityItem | null>(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [gridSize, setGridSize] = useState<number | null>(null);
const matrixRef = useRef<HTMLDivElement>(null);
const gridContainerRef = useRef<HTMLDivElement>(null);
const componentRef = useRef<HTMLDivElement>(null);
const [reviewModal, setReviewModal] = useState<{ reviewId: string; spanId: string } | null>(null);
// Calculate grid size based on available space
useEffect(() => {
const updateGridSize = () => {
// Calculate max available height for the grid (viewport - margins - header - labels - legend)
const maxAvailableHeight = window.innerHeight - 300;
// Get component width, then subtract detail panel min (280px) + y-axis label (40px) + gaps (32px)
const componentWidth = componentRef.current?.offsetWidth ?? window.innerWidth * 0.6;
const maxAvailableWidth = componentWidth - 280 - 40 - 32; // detail panel min + y-label + padding/gaps
// Grid must be square - use the smaller of available height or width
const size = Math.min(maxAvailableHeight, maxAvailableWidth);
setGridSize(Math.max(250, size));
};
// Delay initial calculation to allow layout to settle
const timeoutId = setTimeout(updateGridSize, 50);
window.addEventListener('resize', updateGridSize);
// Also observe component width changes
if (componentRef.current) {
const resizeObserver = new ResizeObserver(updateGridSize);
resizeObserver.observe(componentRef.current);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updateGridSize);
resizeObserver.disconnect();
};
}
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', updateGridSize);
};
}, []);
if (!matrix) {
return null;
}
const totalItems =
matrix.quick_wins.length +
matrix.critical.length +
matrix.nice_to_have.length +
matrix.strategic.length;
if (totalItems === 0) {
return null;
}
const handleItemClick = (item: OpportunityItem) => {
setSelectedItem(item);
onSubcodeClick?.(item.subcode);
};
const handleItemHover = (item: OpportunityItem | null) => {
setHoveredItem(item);
};
const handleMouseMove = (e: React.MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
};
return (
<div
ref={componentRef}
className="bg-white rounded-xl p-4 shadow-md border-2 border-gray-200"
style={{
maxHeight: 'calc(100vh - 120px)',
}}
onMouseMove={handleMouseMove}
>
<div className="flex items-center gap-2 mb-3 flex-shrink-0">
<LayoutGrid className="w-5 h-5 text-indigo-600" />
<h3 className="text-lg font-bold text-gray-900">Opportunity Matrix</h3>
<span className="ml-auto text-xs text-gray-500 bg-gray-100 px-2 py-1 rounded-full">
{totalItems} opportunities
</span>
</div>
{/* Main layout: Matrix + Detail Panel */}
<div className="flex gap-4 items-start">
{/* Matrix section */}
<div ref={matrixRef} className="flex-shrink-0">
{/* Matrix with L-shaped label area */}
<div className="flex">
{/* Y-axis label column - height matches grid */}
<div
className="flex items-center justify-center w-8 mr-2"
style={{ height: gridSize ?? 300 }}
>
<span className="-rotate-90 text-sm text-gray-600 font-semibold whitespace-nowrap">
Effort
</span>
</div>
{/* Main matrix area */}
<div ref={gridContainerRef} className="flex flex-col">
{/* Matrix with arrows */}
<div className="relative">
{/* Coordinate axes - L-shaped from bottom-left origin */}
<svg
className="absolute pointer-events-none overflow-visible"
style={{
zIndex: 5,
width: gridSize ?? 300,
height: gridSize ?? 300,
left: 12,
top: 0,
}}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
<defs>
{/* Horizontal arrowhead - points right */}
<marker
id="arrowhead-right"
markerWidth="6"
markerHeight="6"
refX="5"
refY="3"
orient="0"
markerUnits="strokeWidth"
>
<path d="M0,0 L6,3 L0,6 Z" fill="#6b7280" />
</marker>
{/* Vertical arrowhead - points up (pre-rotated triangle) */}
<marker
id="arrowhead-up"
markerWidth="6"
markerHeight="6"
refX="3"
refY="1"
orient="0"
markerUnits="strokeWidth"
>
<path d="M0,6 L3,0 L6,6 Z" fill="#6b7280" />
</marker>
</defs>
{/* X-axis (horizontal - frequency increases right) */}
<line
x1="-1" y1="102"
x2="99" y2="102"
stroke="#6b7280"
strokeWidth="2.5"
markerEnd="url(#arrowhead-right)"
vectorEffect="non-scaling-stroke"
/>
{/* Y-axis (vertical - effort increases up) */}
<line
x1="-1" y1="102"
x2="-1" y2="1"
stroke="#6b7280"
strokeWidth="2.5"
markerEnd="url(#arrowhead-up)"
vectorEffect="non-scaling-stroke"
/>
</svg>
<div
className="relative ml-3 mb-3"
style={{
width: gridSize ?? 300,
height: gridSize ?? 300,
maxWidth: 'calc(100% - 12px)',
}}
>
{/* Unified coordinate grid overlay */}
<svg
className="absolute top-0 left-0 pointer-events-none"
style={{ zIndex: 2, width: '100%', height: '100%' }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* Vertical grid lines */}
{[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((x) => (
<line
key={`grid-v-${x}`}
x1={x}
y1="0"
x2={x}
y2="100"
stroke="#9ca3af"
strokeWidth="1"
opacity="0.15"
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
))}
{/* Horizontal grid lines */}
{[12.5, 25, 37.5, 50, 62.5, 75, 87.5].map((y) => (
<line
key={`grid-h-${y}`}
x1="0"
y1={y}
x2="100"
y2={y}
stroke="#9ca3af"
strokeWidth="1"
opacity="0.15"
strokeDasharray="3 3"
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
<div className="grid grid-cols-2 grid-rows-2 gap-2 w-full h-full">
{/* Top Row: Simple solutions */}
<Quadrant
title="Nice to Have"
icon={<Clock className="w-3 h-3 text-gray-600" />}
items={matrix.nice_to_have}
bgColor="bg-gray-50"
borderColor="border-gray-200"
textColor="text-gray-700"
iconBg="bg-gray-200"
onItemClick={handleItemClick}
onItemHover={handleItemHover}
selectedItem={selectedItem}
/>
<Quadrant
title="Quick Wins"
icon={<Zap className="w-3 h-3 text-green-600" />}
items={matrix.quick_wins}
bgColor="bg-green-50"
borderColor="border-green-200"
textColor="text-green-700"
iconBg="bg-green-200"
onItemClick={handleItemClick}
onItemHover={handleItemHover}
selectedItem={selectedItem}
/>
{/* Bottom Row: Complex solutions */}
<Quadrant
title="Strategic"
icon={<Target className="w-3 h-3 text-purple-600" />}
items={matrix.strategic}
bgColor="bg-purple-50"
borderColor="border-purple-200"
textColor="text-purple-700"
iconBg="bg-purple-200"
onItemClick={handleItemClick}
onItemHover={handleItemHover}
selectedItem={selectedItem}
/>
<Quadrant
title="Critical"
icon={<Target className="w-3 h-3 text-red-600" />}
items={matrix.critical}
bgColor="bg-red-50"
borderColor="border-red-200"
textColor="text-red-700"
iconBg="bg-red-200"
onItemClick={handleItemClick}
onItemHover={handleItemHover}
selectedItem={selectedItem}
/>
</div>
</div>
</div>
{/* X-axis label row */}
<div className="flex justify-center mt-4 flex-shrink-0">
<span className="text-sm text-gray-600 font-semibold">
Frequency
</span>
</div>
{/* Legend */}
<div className="mt-3 pt-3 border-t border-gray-100 flex flex-wrap gap-3 text-xs text-gray-500 flex-shrink-0">
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-green-200 rounded" />
<span>Quick Wins</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-red-200 rounded" />
<span>Critical</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-purple-200 rounded" />
<span>Strategic</span>
</div>
<div className="flex items-center gap-1">
<div className="w-3 h-3 bg-gray-200 rounded" />
<span>Nice to Have</span>
</div>
</div>
</div>
</div>
</div>
{/* Detail Panel - expands horizontally, height matches grid + labels */}
<div
className="flex-1 min-w-[280px] overflow-hidden"
style={{ height: gridSize ? gridSize + 80 : 'auto' }}
>
{selectedItem ? (
<DetailPanel
item={selectedItem}
onClose={() => setSelectedItem(null)}
onViewReview={(reviewId, spanId) => setReviewModal({ reviewId, spanId })}
/>
) : (
<div className="bg-gray-50 rounded-xl border-2 border-dashed border-gray-200 p-6 h-full flex flex-col items-center justify-center text-center">
<div className="w-12 h-12 bg-gray-100 rounded-full flex items-center justify-center mb-3">
<Target className="w-6 h-6 text-gray-400" />
</div>
<h4 className="font-medium text-gray-600 mb-1">Select an opportunity</h4>
<p className="text-sm text-gray-400 max-w-[200px]">
Click on any item in the matrix to see details and suggested actions
</p>
</div>
)}
</div>
</div>
{/* Hover Card */}
{hoveredItem && !selectedItem && (
<HoverCard item={hoveredItem} position={mousePos} />
)}
{/* Review Modal */}
{reviewModal && (
<ReviewModal
reviewId={reviewModal.reviewId}
highlightSpanId={reviewModal.spanId}
onClose={() => setReviewModal(null)}
/>
)}
</div>
);
}