'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 => (
A
B
{t('insightsView.suggestions.bridging', { clusterA: s.clusterAName, clusterB: s.clusterBName, })}

{s.suggestedTitle}

{s.suggestedContent}

{s.justification}
))} {isCalculating && (
{[1, 2].map(i => (
))}
)} {!isCalculating && suggestions.length === 0 && (
{t('insightsView.suggestions.emptyDescription')}
)}
)}
) }