Move flat src/ layout into packages/ monorepo: - packages/core: scraping, embeddings, storage, clustering, analysis - packages/cli: CLI and TUI interface - packages/web: Next.js web dashboard Add playwright screenshots, sqlite storage, and settings.
817 lines
28 KiB
TypeScript
817 lines
28 KiB
TypeScript
'use client'
|
|
|
|
import React, { useEffect, useState, useMemo, useCallback } from 'react'
|
|
import { useToast } from '@/components/ui/toast'
|
|
import {
|
|
BarChart,
|
|
Bar,
|
|
PieChart,
|
|
Pie,
|
|
Cell,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
Legend,
|
|
ResponsiveContainer,
|
|
} from 'recharts'
|
|
|
|
interface Problem {
|
|
clusterId: number
|
|
problem: string
|
|
description: string
|
|
size: number
|
|
totalEngagement: number
|
|
lastActive: number
|
|
subreddits: string[]
|
|
sampleQuestions: string[]
|
|
impactScore?: number
|
|
}
|
|
|
|
interface DiscussionSample {
|
|
id: string
|
|
type: 'post' | 'comment'
|
|
subreddit: string
|
|
title?: string
|
|
author: string
|
|
body: string
|
|
score: number
|
|
created: number
|
|
permalink: string
|
|
parent_id?: string
|
|
}
|
|
|
|
function DiscussionSamples({ clusterId }: { clusterId: number }) {
|
|
const [samples, setSamples] = useState<DiscussionSample[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [expandedBodies, setExpandedBodies] = useState<Set<string>>(new Set())
|
|
|
|
useEffect(() => {
|
|
fetch(`/api/clusters/${clusterId}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setSamples(data.samples || [])
|
|
setLoading(false)
|
|
})
|
|
.catch(() => setLoading(false))
|
|
}, [clusterId])
|
|
|
|
const toggleBody = useCallback((id: string) => {
|
|
setExpandedBodies(prev => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) {
|
|
next.delete(id)
|
|
} else {
|
|
next.add(id)
|
|
}
|
|
return next
|
|
})
|
|
}, [])
|
|
|
|
const formatDate = (timestamp: number) => {
|
|
const date = new Date(timestamp * 1000)
|
|
return date.toLocaleDateString()
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="text-sm text-muted-foreground py-2">
|
|
Loading discussions...
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (samples.length === 0) {
|
|
return (
|
|
<div className="text-sm text-muted-foreground py-2">
|
|
No discussion samples available. Re-run clustering to populate.
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="text-sm font-medium text-muted-foreground">
|
|
Discussion Samples ({samples.length})
|
|
</div>
|
|
{samples.map(sample => {
|
|
const isExpanded = expandedBodies.has(sample.id)
|
|
const bodyTruncated = sample.body.length > 300
|
|
const displayBody = isExpanded ? sample.body : sample.body.slice(0, 300)
|
|
|
|
return (
|
|
<div
|
|
key={sample.id}
|
|
className="rounded-lg border border-border bg-background p-3 space-y-2"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${
|
|
sample.type === 'post'
|
|
? 'bg-blue-500/10 text-blue-500'
|
|
: 'bg-green-500/10 text-green-500'
|
|
}`}
|
|
>
|
|
{sample.type}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
r/{sample.subreddit}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">•</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
u/{sample.author}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">•</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{sample.score} pts
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">•</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{formatDate(sample.created)}
|
|
</span>
|
|
</div>
|
|
<a
|
|
href={`https://reddit.com${sample.permalink}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-primary hover:underline shrink-0"
|
|
>
|
|
view on reddit →
|
|
</a>
|
|
</div>
|
|
|
|
{sample.title && (
|
|
<div className="font-medium text-foreground">{sample.title}</div>
|
|
)}
|
|
|
|
<div className="text-sm text-foreground whitespace-pre-wrap">
|
|
{displayBody}
|
|
{bodyTruncated && !isExpanded && '...'}
|
|
</div>
|
|
|
|
{bodyTruncated && (
|
|
<button
|
|
onClick={() => toggleBody(sample.id)}
|
|
className="text-xs text-primary hover:underline"
|
|
>
|
|
{isExpanded ? 'show less' : 'show more'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface Weights {
|
|
engagement: number
|
|
velocity: number
|
|
sentiment: number
|
|
}
|
|
|
|
interface SimilarityData {
|
|
matrix: number[][]
|
|
labels: string[]
|
|
clusterIds: number[]
|
|
}
|
|
|
|
function CorrelationHeatmap({
|
|
onCellClick,
|
|
}: {
|
|
onCellClick?: (clusterIds: [number, number]) => void
|
|
}) {
|
|
const [data, setData] = useState<SimilarityData | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number } | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetch('/api/clusters/similarity')
|
|
.then(res => res.json())
|
|
.then(d => {
|
|
setData(d)
|
|
setLoading(false)
|
|
})
|
|
.catch(() => setLoading(false))
|
|
}, [])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-muted-foreground">Loading correlation data...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!data || data.matrix.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="text-sm text-muted-foreground">No clusters to compare</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const getColor = (value: number) => {
|
|
// white (0) -> indigo (1)
|
|
const intensity = Math.round(value * 255)
|
|
return `rgb(${255 - intensity * 0.6}, ${255 - intensity * 0.62}, ${255 - intensity * 0.05})`
|
|
}
|
|
|
|
const n = data.matrix.length
|
|
const cellSize = Math.max(24, Math.min(40, 400 / n))
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div className="overflow-x-auto">
|
|
<div
|
|
className="inline-grid gap-px bg-border"
|
|
style={{
|
|
gridTemplateColumns: `auto repeat(${n}, ${cellSize}px)`,
|
|
gridTemplateRows: `auto repeat(${n}, ${cellSize}px)`,
|
|
}}
|
|
>
|
|
{/* empty corner cell */}
|
|
<div className="bg-card" />
|
|
|
|
{/* column headers */}
|
|
{data.labels.map((label, j) => (
|
|
<div
|
|
key={`col-${j}`}
|
|
className="bg-card flex items-end justify-center pb-1 px-1"
|
|
style={{ height: cellSize * 2 }}
|
|
>
|
|
<span
|
|
className="text-[10px] text-muted-foreground origin-bottom-left whitespace-nowrap overflow-hidden text-ellipsis"
|
|
style={{
|
|
transform: 'rotate(-45deg)',
|
|
maxWidth: cellSize * 2,
|
|
}}
|
|
title={label}
|
|
>
|
|
{label.slice(0, 15)}
|
|
</span>
|
|
</div>
|
|
))}
|
|
|
|
{/* rows */}
|
|
{data.matrix.map((row, i) => (
|
|
<React.Fragment key={`row-${i}`}>
|
|
{/* row label */}
|
|
<div
|
|
className="bg-card flex items-center justify-end pr-2"
|
|
style={{ width: 100 }}
|
|
>
|
|
<span
|
|
className="text-[10px] text-muted-foreground truncate"
|
|
title={data.labels[i]}
|
|
>
|
|
{data.labels[i].slice(0, 12)}
|
|
</span>
|
|
</div>
|
|
|
|
{/* cells */}
|
|
{row.map((value, j) => {
|
|
const isHovered = hoveredCell?.i === i && hoveredCell?.j === j
|
|
return (
|
|
<div
|
|
key={`cell-${i}-${j}`}
|
|
className="relative cursor-pointer transition-all"
|
|
style={{
|
|
backgroundColor: getColor(value),
|
|
width: cellSize,
|
|
height: cellSize,
|
|
outline: isHovered ? '2px solid #6366f1' : 'none',
|
|
outlineOffset: '-1px',
|
|
}}
|
|
onMouseEnter={() => setHoveredCell({ i, j })}
|
|
onMouseLeave={() => setHoveredCell(null)}
|
|
onClick={() => onCellClick?.([data.clusterIds[i], data.clusterIds[j]])}
|
|
title={`${data.labels[i]} ↔ ${data.labels[j]}: ${(value * 100).toFixed(0)}%`}
|
|
>
|
|
{isHovered && (
|
|
<div className="absolute z-10 bg-card border border-border rounded px-2 py-1 text-xs shadow-lg whitespace-nowrap -translate-x-1/2 left-1/2 -top-8">
|
|
{(value * 100).toFixed(0)}% similar
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* legend */}
|
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<span>0%</span>
|
|
<div
|
|
className="h-3 w-32 rounded"
|
|
style={{
|
|
background: 'linear-gradient(to right, white, #6366f1)',
|
|
}}
|
|
/>
|
|
<span>100%</span>
|
|
<span className="ml-2">keyword overlap</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// chart colors - recharts can't parse CSS variables at runtime
|
|
const CHART_COLORS = {
|
|
primary: '#6366f1', // indigo
|
|
secondary: '#8b5cf6', // violet
|
|
accent: '#06b6d4', // cyan
|
|
muted: '#94a3b8', // slate
|
|
grid: '#e2e8f0', // light grid
|
|
text: '#64748b', // muted text
|
|
palette: ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#84cc16'],
|
|
}
|
|
|
|
export default function ProblemsPage() {
|
|
const [problems, setProblems] = useState<Problem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [expanded, setExpanded] = useState<number | null>(null)
|
|
const [clustering, setClustering] = useState(false)
|
|
const { addToast, updateToast } = useToast()
|
|
|
|
const [similarityThreshold, setSimilarityThreshold] = useState(0.5)
|
|
const [minClusterSize, setMinClusterSize] = useState(2)
|
|
const [weights, setWeights] = useState<Weights>({
|
|
engagement: 0.5,
|
|
velocity: 0.3,
|
|
sentiment: 0.2,
|
|
})
|
|
|
|
const fetchClusters = () => {
|
|
fetch('/api/clusters')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setProblems(data.clusters || [])
|
|
setLoading(false)
|
|
})
|
|
.catch(() => setLoading(false))
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchClusters()
|
|
}, [])
|
|
|
|
const sortedProblems = useMemo(() => {
|
|
if (problems.length === 0) return []
|
|
|
|
const now = Math.floor(Date.now() / 1000)
|
|
const oneWeek = 7 * 24 * 60 * 60
|
|
const maxEngagement = Math.max(...problems.map(p => p.totalEngagement))
|
|
|
|
return [...problems]
|
|
.map(p => {
|
|
const engagementScore = maxEngagement > 0 ? p.totalEngagement / maxEngagement : 0
|
|
const age = now - p.lastActive
|
|
const velocityScore = Math.max(0, 1 - age / oneWeek)
|
|
const sentimentScore = 0.5
|
|
|
|
const impactScore =
|
|
weights.engagement * engagementScore +
|
|
weights.velocity * velocityScore +
|
|
weights.sentiment * sentimentScore
|
|
|
|
return { ...p, impactScore }
|
|
})
|
|
.sort((a, b) => (b.impactScore || 0) - (a.impactScore || 0))
|
|
}, [problems, weights])
|
|
|
|
const chartData = useMemo(() => {
|
|
if (sortedProblems.length === 0) return { impact: [], subreddits: [], sizes: [] }
|
|
|
|
const impactData = sortedProblems.slice(0, 10).map(p => ({
|
|
name: p.problem.slice(0, 30) + (p.problem.length > 30 ? '...' : ''),
|
|
impact: Math.round((p.impactScore || 0) * 100),
|
|
engagement: p.totalEngagement,
|
|
discussions: p.size,
|
|
}))
|
|
|
|
const subredditCounts = new Map<string, number>()
|
|
sortedProblems.forEach(p => {
|
|
p.subreddits.forEach(sub => {
|
|
subredditCounts.set(sub, (subredditCounts.get(sub) || 0) + 1)
|
|
})
|
|
})
|
|
const subredditData = Array.from(subredditCounts.entries())
|
|
.map(([name, value]) => ({ name: `r/${name}`, value }))
|
|
.sort((a, b) => b.value - a.value)
|
|
.slice(0, 8)
|
|
|
|
const sizeDistribution = sortedProblems.reduce((acc, p) => {
|
|
const bucket =
|
|
p.size < 5 ? '2-4' : p.size < 10 ? '5-9' : p.size < 20 ? '10-19' : '20+'
|
|
acc[bucket] = (acc[bucket] || 0) + 1
|
|
return acc
|
|
}, {} as Record<string, number>)
|
|
const sizeData = Object.entries(sizeDistribution).map(([name, value]) => ({
|
|
name: `${name} discussions`,
|
|
value,
|
|
}))
|
|
|
|
return { impact: impactData, subreddits: subredditData, sizes: sizeData }
|
|
}, [sortedProblems])
|
|
|
|
const handleRecluster = async () => {
|
|
setClustering(true)
|
|
const toastId = addToast('Clustering discussions...', 'loading')
|
|
try {
|
|
const res = await fetch('/api/clusters', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ similarityThreshold, minClusterSize }),
|
|
})
|
|
const data = await res.json()
|
|
if (data.success) {
|
|
updateToast(toastId, `Found ${data.clusters?.length || 0} problem clusters`, 'success')
|
|
fetchClusters()
|
|
} else {
|
|
updateToast(toastId, data.error || 'Clustering failed', 'error')
|
|
}
|
|
} catch (e) {
|
|
updateToast(toastId, 'Clustering failed - check console', 'error')
|
|
} finally {
|
|
setClustering(false)
|
|
}
|
|
}
|
|
|
|
const updateWeight = (key: keyof Weights, value: number) => {
|
|
setWeights(prev => ({ ...prev, [key]: value }))
|
|
}
|
|
|
|
const formatDate = (timestamp: number) => {
|
|
const date = new Date(timestamp * 1000)
|
|
return date.toLocaleDateString()
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-64">
|
|
<div className="text-muted-foreground">Loading...</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const clusterControls = (
|
|
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
|
<div className="text-sm font-medium text-foreground">Clustering Settings</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs text-muted-foreground mb-1">
|
|
Similarity Threshold: {similarityThreshold.toFixed(2)}
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="0.3"
|
|
max="0.9"
|
|
step="0.05"
|
|
value={similarityThreshold}
|
|
onChange={e => setSimilarityThreshold(parseFloat(e.target.value))}
|
|
className="w-full accent-primary"
|
|
/>
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
<span>Loose (0.3)</span>
|
|
<span>Strict (0.9)</span>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-muted-foreground mb-1">
|
|
Min Cluster Size
|
|
</label>
|
|
<select
|
|
value={minClusterSize}
|
|
onChange={e => setMinClusterSize(parseInt(e.target.value))}
|
|
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground"
|
|
>
|
|
<option value={2}>2 discussions</option>
|
|
<option value={3}>3 discussions</option>
|
|
<option value={5}>5 discussions</option>
|
|
<option value={10}>10 discussions</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const weightControls = (
|
|
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
|
<div className="text-sm font-medium text-foreground">Impact Score Weights</div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-xs text-muted-foreground mb-1">
|
|
Engagement: {(weights.engagement * 100).toFixed(0)}%
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={weights.engagement}
|
|
onChange={e => updateWeight('engagement', parseFloat(e.target.value))}
|
|
className="w-full accent-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-muted-foreground mb-1">
|
|
Velocity: {(weights.velocity * 100).toFixed(0)}%
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={weights.velocity}
|
|
onChange={e => updateWeight('velocity', parseFloat(e.target.value))}
|
|
className="w-full accent-primary"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-muted-foreground mb-1">
|
|
Sentiment: {(weights.sentiment * 100).toFixed(0)}%
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.1"
|
|
value={weights.sentiment}
|
|
onChange={e => updateWeight('sentiment', parseFloat(e.target.value))}
|
|
className="w-full accent-primary"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
if (problems.length === 0) {
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
|
|
<p className="text-muted-foreground">View and analyze problem clusters</p>
|
|
</div>
|
|
{clusterControls}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleRecluster}
|
|
disabled={clustering}
|
|
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{clustering ? 'Clustering...' : 'Run Clustering'}
|
|
</button>
|
|
</div>
|
|
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
|
<p className="text-muted-foreground">No problems found. Adjust settings and run clustering.</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
|
|
<p className="text-muted-foreground">
|
|
{sortedProblems.length} problem clusters identified
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={handleRecluster}
|
|
disabled={clustering}
|
|
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
{clustering ? 'Clustering...' : 'Re-cluster'}
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{clusterControls}
|
|
{weightControls}
|
|
</div>
|
|
|
|
{sortedProblems.length > 0 && (
|
|
<div className="space-y-4">
|
|
<div className="text-lg font-medium text-foreground">Problem Analytics</div>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<div className="text-sm font-medium text-foreground mb-4">
|
|
Top Problems by Impact Score
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<BarChart data={chartData.impact}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
|
<XAxis
|
|
dataKey="name"
|
|
angle={-45}
|
|
textAnchor="end"
|
|
height={100}
|
|
tick={{ fill: CHART_COLORS.text, fontSize: 11 }}
|
|
/>
|
|
<YAxis tick={{ fill: CHART_COLORS.text }} />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#fff',
|
|
border: `1px solid ${CHART_COLORS.grid}`,
|
|
borderRadius: '6px',
|
|
}}
|
|
/>
|
|
<Bar dataKey="impact" fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<div className="text-sm font-medium text-foreground mb-4">
|
|
Discussion Distribution by Subreddit
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={300}>
|
|
<PieChart>
|
|
<Pie
|
|
data={chartData.subreddits}
|
|
cx="50%"
|
|
cy="50%"
|
|
labelLine={false}
|
|
label={({ name, percent }) =>
|
|
`${name} (${(percent * 100).toFixed(0)}%)`
|
|
}
|
|
outerRadius={80}
|
|
dataKey="value"
|
|
>
|
|
{chartData.subreddits.map((_, index) => (
|
|
<Cell
|
|
key={`cell-${index}`}
|
|
fill={CHART_COLORS.palette[index % CHART_COLORS.palette.length]}
|
|
/>
|
|
))}
|
|
</Pie>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#fff',
|
|
border: `1px solid ${CHART_COLORS.grid}`,
|
|
borderRadius: '6px',
|
|
}}
|
|
/>
|
|
</PieChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<div className="text-sm font-medium text-foreground mb-4">
|
|
Cluster Size Distribution
|
|
</div>
|
|
<ResponsiveContainer width="100%" height={250}>
|
|
<BarChart data={chartData.sizes}>
|
|
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
|
<XAxis
|
|
dataKey="name"
|
|
tick={{ fill: CHART_COLORS.text }}
|
|
/>
|
|
<YAxis tick={{ fill: CHART_COLORS.text }} allowDecimals={false} />
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: '#fff',
|
|
border: `1px solid ${CHART_COLORS.grid}`,
|
|
borderRadius: '6px',
|
|
}}
|
|
/>
|
|
<Bar dataKey="value" fill={CHART_COLORS.accent} radius={[4, 4, 0, 0]} />
|
|
</BarChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<div className="text-sm font-medium text-foreground mb-4">Key Metrics</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{sortedProblems.length}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Total Clusters</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{sortedProblems.reduce((sum, p) => sum + p.size, 0).toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Total Discussions</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{sortedProblems
|
|
.reduce((sum, p) => sum + p.totalEngagement, 0)
|
|
.toLocaleString()}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Total Upvotes</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold text-foreground">
|
|
{new Set(sortedProblems.flatMap(p => p.subreddits)).size}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">Unique Subreddits</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* correlation heatmap */}
|
|
<div className="rounded-lg border border-border bg-card p-4">
|
|
<div className="text-sm font-medium text-foreground mb-4">
|
|
Problem Cluster Correlation
|
|
</div>
|
|
<CorrelationHeatmap
|
|
onCellClick={([id1, id2]) => {
|
|
// expand first cluster that isn't already expanded
|
|
if (expanded !== id1) {
|
|
setExpanded(id1)
|
|
} else if (expanded !== id2) {
|
|
setExpanded(id2)
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-muted">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Problem
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Impact
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Discussions
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Upvotes
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
Last Active
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{sortedProblems.map(problem => (
|
|
<React.Fragment key={problem.clusterId}>
|
|
<tr
|
|
onClick={() => setExpanded(expanded === problem.clusterId ? null : problem.clusterId)}
|
|
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="font-medium text-foreground">{problem.problem}</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{problem.subreddits.map(s => `r/${s}`).join(', ')}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 text-foreground">
|
|
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
|
{((problem.impactScore || 0) * 100).toFixed(0)}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-foreground">{problem.size}</td>
|
|
<td className="px-4 py-3 text-foreground">
|
|
{problem.totalEngagement.toLocaleString()}
|
|
</td>
|
|
<td className="px-4 py-3 text-foreground">{formatDate(problem.lastActive)}</td>
|
|
</tr>
|
|
{expanded === problem.clusterId && (
|
|
<tr className="border-t border-border bg-muted/50">
|
|
<td colSpan={5} className="px-4 py-4">
|
|
<div className="space-y-4">
|
|
<p className="text-foreground">{problem.description}</p>
|
|
|
|
<DiscussionSamples clusterId={problem.clusterId} />
|
|
|
|
{problem.sampleQuestions.length > 0 && (
|
|
<div>
|
|
<div className="text-sm font-medium text-muted-foreground mb-1">
|
|
Sample Questions:
|
|
</div>
|
|
<ul className="list-disc list-inside text-sm text-foreground">
|
|
{problem.sampleQuestions.map((q, i) => (
|
|
<li key={i}>{q}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</React.Fragment>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|