'use client'
import { useState, useEffect, useMemo, useCallback } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { useLanguage } from '@/lib/i18n'
import { motion, AnimatePresence, useReducedMotion } from 'motion/react'
import {
Sparkles,
RefreshCw,
Layers,
Trophy,
Zap,
Lightbulb,
Sliders,
CheckCircle2,
Clock,
AlertCircle,
ChevronRight,
Database,
ArrowRight,
Menu,
Network,
List,
} from 'lucide-react'
import { toast } from 'sonner'
import Link from 'next/link'
const NetworkGraph = dynamic(
() => import('@/components/network-graph').then(m => ({ default: m.NetworkGraph })),
{
loading: () => (
),
ssr: false,
}
)
interface Note {
id: string
title: string | null
content: string
clusterId?: number
}
interface Cluster {
id: string
clusterId: number
name?: string
noteIds: string[]
color?: string
}
interface BridgeNote {
noteId: string
bridgeScore: number
clustersConnected: number[]
clusterNames?: string[]
note?: {
id: string
title: string | null
content: string
}
}
interface BridgeSuggestion {
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}
const COLOR_PALETTE = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF']
export default function InsightsPage() {
const router = useRouter()
const { t, language: locale } = useLanguage()
const formatSyncTime = useCallback(
(date: Date) =>
date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }),
[locale]
)
const [notes, setNotes] = useState([])
const [clusters, setClusters] = useState([])
const [bridgeNotes, setBridgeNotes] = useState([])
const [suggestions, setSuggestions] = useState([])
const [loading, setLoading] = useState(true)
const [isCalculating, setIsCalculating] = useState(false)
const [isReindexing, setIsReindexing] = useState(false)
const [embeddingStats, setEmbeddingStats] = useState<{ indexed: number; total: number } | null>(null)
const [isStale, setIsStale] = useState(false)
const [selectedClusterId, setSelectedClusterId] = useState(null)
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard')
const [graphMode, setGraphMode] = useState<'visual' | 'list'>('visual')
const [lastSyncTime, setLastSyncTime] = useState('')
const prefersReducedMotion = useReducedMotion()
useEffect(() => {
loadInitialData()
}, [])
// ─── Données calculées ───────────────────────────────────────────────────────
const selectedCluster = useMemo(
() => clusters.find(c => c.id === selectedClusterId) ?? null,
[clusters, selectedClusterId]
)
const selectedClusterNotes = useMemo(
() => (selectedCluster ? notes.filter(n => selectedCluster.noteIds.includes(n.id)) : []),
[notes, selectedCluster]
)
const isolatedClusters = useMemo(() => {
const networkedIds = new Set(
bridgeNotes.flatMap(b => b.clustersConnected.map(cid => String(cid)))
)
return clusters.filter(c => !networkedIds.has(c.id))
}, [clusters, bridgeNotes])
const bridgeList = useMemo(
() =>
bridgeNotes.map(b => ({
...b,
title: b.note?.title || t('insightsView.unknownNote'),
})),
[bridgeNotes, t]
)
// ─── Chargement initial ──────────────────────────────────────────────────────
const loadInitialData = async () => {
setLoading(true)
try {
const res = await fetch('/api/clusters')
if (res.ok) {
const data = await res.json()
if (data.clusters?.length > 0) {
const clustersWithColors = data.clusters.map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setIsStale(!!data.stale)
// Bridge notes incluses dans la réponse GET /clusters (enrichies)
if (data.bridgeNotes?.length > 0) {
setBridgeNotes(data.bridgeNotes)
} else {
const bridgeRes = await fetch('/api/bridge-notes?details=true')
if (bridgeRes.ok) {
const bridgeData = await bridgeRes.json()
setBridgeNotes(bridgeData.bridgeNotes || [])
}
}
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(formatSyncTime(new Date()))
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
} else {
setIsStale(false)
if (typeof data.embeddingCount === 'number' && typeof data.totalNotes === 'number') {
setEmbeddingStats({ indexed: data.embeddingCount, total: data.totalNotes })
}
}
}
} catch (error) {
console.error('Error loading data:', error)
} finally {
setLoading(false)
}
}
const handleReindexEmbeddings = async () => {
setIsReindexing(true)
try {
const res = await fetch('/api/notes/reindex', { method: 'POST' })
if (!res.ok) throw new Error('reindex failed')
const data = await res.json()
setEmbeddingStats(prev => ({
indexed: data.count ?? prev?.indexed ?? 0,
total: data.total ?? prev?.total ?? notes.length,
}))
setLastSyncTime(formatSyncTime(new Date()))
setIsStale(true)
} catch (error) {
console.error('Error reindexing embeddings:', error)
} finally {
setIsReindexing(false)
}
}
// ─── Analyse (POST) ──────────────────────────────────────────────────────────
const performAnalysis = async () => {
setIsCalculating(true)
try {
const res = await fetch('/api/clusters', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force: true })
})
if (res.ok) {
const data = await res.json()
const clusterCount = data.clusters?.length || 0
if (clusterCount === 0) {
toast.info(t('insightsView.analysisNoClusters'))
} else {
toast.success(t('insightsView.analysisSuccess', { count: clusterCount }))
}
const clustersWithColors = (data.clusters || []).map((c: Cluster, i: number) => ({
...c,
id: c.clusterId.toString(),
color: COLOR_PALETTE[i % COLOR_PALETTE.length]
}))
setNotes(data.notes || [])
setClusters(clustersWithColors)
setBridgeNotes(data.bridgeNotes || [])
setIsStale(false)
const suggestionsRes = await fetch('/api/bridge-notes/suggestions')
if (suggestionsRes.ok) {
const suggestionsData = await suggestionsRes.json()
setSuggestions(suggestionsData.suggestions || [])
}
setLastSyncTime(formatSyncTime(new Date()))
if (data.notes?.length) {
setEmbeddingStats(prev => ({
indexed: prev?.indexed ?? data.notes.length,
total: data.notes.length,
}))
}
}
} catch (error) {
console.error('Error running analysis:', error)
toast.error(t('insightsView.analysisFailed'))
} finally {
setIsCalculating(false)
}
}
const handleNoteClick = (noteId: string) => {
router.push(`/home?openNote=${noteId}`)
}
const motionConfig = prefersReducedMotion
? { initial: false as const, animate: { opacity: 1, y: 0 }, transition: { duration: 0 } }
: {}
// ─── Rendu ───────────────────────────────────────────────────────────────────────────
return (
{/* ── Header ── */}
{t('insightsView.title')}
{t('insightsView.subtitle')}
{t('insightsView.semanticGraphLegend')}
{t('insightsView.openGraphMap')}
{/* Tab switcher mobile */}
{/* ── Chargement ── */}
{loading && (
{t('insightsView.loading')}
)}
{/* ── État vide ── */}
{!loading && clusters.length === 0 && !isCalculating && (
{t('insightsView.emptyTitle')}
{embeddingStats && embeddingStats.total < 10
? t('insightsView.emptyNeedMoreNotes', { count: 10 - embeddingStats.total })
: t('insightsView.emptyDescription')}
)}
{/* ── Calcul en cours ── */}
{isCalculating && !loading && (
{t('insightsView.mappingTitle')}
{t('insightsView.mappingHint')}
)}
{/* ── Contenu principal ── */}
{!loading && clusters.length > 0 && !isCalculating && (
{/* ── Graphe / Liste accessible (gauche) ── */}
{/* Toggle visual/list accessible */}
{/* Vue visuelle (D3) */}
{graphMode === 'visual' && (
)}
{/* Vue liste accessible (a11y fallback) */}
{graphMode === 'list' && (
{clusters.map(cluster => {
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id))
const clusterBridges = bridgeNotes.filter(b =>
b.clustersConnected?.some(cid => String(cid) === cluster.id)
)
return (
{cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId })}
{clusterNotes.length} {t('insightsView.graphNotesLabel')}
{clusterBridges.length > 0 && ` · ${clusterBridges.length} ${t('insightsView.bridgeCount')}`}
{clusterNotes.map(note => (
-
))}
)
})}
)}
{/* ── Dashboard (droite) ── */}
{/* Avertissement d'obsolescence (stale banner) */}
{isStale && !isCalculating && (
{t('insightsView.staleResults')}
)}
{/* ① Panneau d'inspection cluster */}
{selectedCluster && (
{t('insightsView.focusCluster.title')}
{selectedCluster.name ||
t('insightsView.clusterFallback', { index: selectedCluster.clusterId })}
{t('insightsView.focusCluster.description', {
count: selectedClusterNotes.length,
})}
{selectedClusterNotes.map(note => (
))}
)}
{/* ② Stats */}
{t('insightsView.stats.clusters')}
{clusters.length}
{t('insightsView.graphNotesLabel')}
{t('insightsView.stats.bridgeNotes')}
{bridgeNotes.length}
{t('insightsView.bridgeCount')}
{/* ③ Système de Recalcul */}
{t('insightsView.recalcSystem.title')}
{t('insightsView.recalcSystem.statusSynced')}
{t('insightsView.recalcSystem.scheduledCron')}
04:00
{t('insightsView.recalcSystem.lastSync')}
{lastSyncTime || '—'}
{embeddingStats
? t('insightsView.embeddingsHint', {
indexed: embeddingStats.indexed,
total: embeddingStats.total,
})
: '—'}
{embeddingStats
? `${embeddingStats.indexed} / ${embeddingStats.total}`
: '—'}
{t('insightsView.tipClusters')}
{t('insightsView.tipClustersAction')}
{/* ④ Clusters Isolés */}
{t('insightsView.isolatedClusters.title', { count: isolatedClusters.length })}
{t('insightsView.tipIsolatedAction')}
{isolatedClusters.map(c => (
setSelectedClusterId(c.id)}
className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 dark:hover:border-white/10 flex items-center justify-between cursor-pointer transition-all focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
>
{c.name || t('insightsView.clusterFallback', { index: c.clusterId })}
{t('insightsView.isolatedClusters.badge')}
))}
{isolatedClusters.length === 0 && (
{t('insightsView.isolatedClusters.empty')}
)}
{/* ⑤ Notes-Ponts Influentes */}
{t('insightsView.bridgeNotes.title')}
{t('insightsView.tipBridgeNotes')}
{bridgeList.map(bridge => (
handleNoteClick(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group focus-visible:ring-2 focus-visible:ring-ochre/50 focus-visible:outline-none"
tabIndex={0}
role="button"
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleNoteClick(bridge.noteId) } }}
>
{bridge.title}
{t('insightsView.bridgeNotes.score', {
score: (bridge.bridgeScore * 100).toFixed(0),
})}
{bridge.clustersConnected.map(cid => {
const cluster = clusters.find(c => c.id === String(cid))
return (
{
e.stopPropagation()
setSelectedClusterId(String(cid))
}}
className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors cursor-pointer"
>
{cluster?.name || t('insightsView.clusterFallback', { index: cid })}
)
})}
))}
{bridgeList.length === 0 && !isCalculating && (
{t('insightsView.bridgeNotes.empty')}
)}
{/* ⑥ Opportunités de Connexion */}
{t('insightsView.suggestions.title')}
{t('insightsView.tipSuggestions')}
{suggestions.map(s => (
{t('insightsView.suggestions.bridging', {
clusterA: s.clusterAName,
clusterB: s.clusterBName,
})}
{s.suggestedTitle}
{s.suggestedContent}
{s.justification}
))}
{isCalculating && (
)}
{!isCalculating && suggestions.length === 0 && (
{t('insightsView.suggestions.emptyDescription')}
)}
)}
)
}