'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, ChevronDown, Database, ArrowRight, Menu, Network, List, Search, ArrowUpDown, X, } from 'lucide-react' import { toast } from 'sonner' import Link from 'next/link' import { useNotePeek, NotePeekPanel } from '@/components/note-peek' import { createNote } from '@/app/actions/notes' import { emitNoteChange } from '@/lib/note-change-sync' 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 [listFilter, setListFilter] = useState('') const [listSort, setListSort] = useState<'size' | 'alpha' | 'bridges'>('size') /** Un seul cluster déplié à la fois (null = tous repliés) */ const [expandedListClusterId, setExpandedListClusterId] = useState(null) const [dashboardFilter, setDashboardFilter] = useState('') const [bridgesShowAll, setBridgesShowAll] = useState(false) const [isolatedShowAll, setIsolatedShowAll] = useState(false) const [suggestionsShowAll, setSuggestionsShowAll] = useState(false) const [actingSuggestionKey, setActingSuggestionKey] = useState(null) const [dashboardSectionOpen, setDashboardSectionOpen] = useState({ recalc: false, isolated: true, bridges: true, suggestions: false, }) const [lastSyncTime, setLastSyncTime] = useState('') const peek = useNotePeek() const prefersReducedMotion = useReducedMotion() const DASHBOARD_PREVIEW = 8 const SUGGESTIONS_PREVIEW = 3 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 listClusters = useMemo(() => { const q = listFilter.trim().toLowerCase() const rows = 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 || cid === cluster.clusterId) ) const name = cluster.name || t('insightsView.clusterFallback', { index: cluster.clusterId }) const matchingNotes = q ? clusterNotes.filter(n => (n.title || '').toLowerCase().includes(q) ) : clusterNotes const nameMatches = !q || name.toLowerCase().includes(q) return { cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches } }) const filtered = q ? rows.filter(r => r.nameMatches || r.matchingNotes.length > 0) : rows const sorted = [...filtered].sort((a, b) => { if (listSort === 'alpha') return a.name.localeCompare(b.name, locale) if (listSort === 'bridges') { const d = b.clusterBridges.length - a.clusterBridges.length return d !== 0 ? d : b.clusterNotes.length - a.clusterNotes.length } // size (default) const d = b.clusterNotes.length - a.clusterNotes.length return d !== 0 ? d : a.name.localeCompare(b.name, locale) }) return sorted }, [clusters, notes, bridgeNotes, listFilter, listSort, locale, t]) const bridgeList = useMemo( () => bridgeNotes.map(b => ({ ...b, title: b.note?.title || t('insightsView.unknownNote'), })), [bridgeNotes, t] ) const filteredIsolatedClusters = useMemo(() => { const q = dashboardFilter.trim().toLowerCase() if (!q) return isolatedClusters return isolatedClusters.filter(c => (c.name || t('insightsView.clusterFallback', { index: c.clusterId })) .toLowerCase() .includes(q) ) }, [isolatedClusters, dashboardFilter, t]) const filteredBridgeList = useMemo(() => { const q = dashboardFilter.trim().toLowerCase() const list = [...bridgeList].sort((a, b) => b.bridgeScore - a.bridgeScore) if (!q) return list return list.filter(b => { const titleMatch = b.title.toLowerCase().includes(q) const clusterMatch = b.clustersConnected.some(cid => { const cluster = clusters.find(c => c.id === String(cid)) const name = cluster?.name || t('insightsView.clusterFallback', { index: cid }) return name.toLowerCase().includes(q) }) return titleMatch || clusterMatch }) }, [bridgeList, dashboardFilter, clusters, t]) const visibleIsolatedClusters = isolatedShowAll || dashboardFilter.trim() ? filteredIsolatedClusters : filteredIsolatedClusters.slice(0, DASHBOARD_PREVIEW) const visibleBridgeList = bridgesShowAll || dashboardFilter.trim() ? filteredBridgeList : filteredBridgeList.slice(0, DASHBOARD_PREVIEW) /** Affiche au plus 2 thèmes (pair de brokerage) ; le reste derrière +N. */ const getBridgeThemeSlice = useCallback((clusterIds: number[]) => { const ranked = [...clusterIds].sort((a, b) => { const sizeA = clusters.find(c => c.id === String(a))?.noteIds.length ?? 0 const sizeB = clusters.find(c => c.id === String(b))?.noteIds.length ?? 0 return sizeB - sizeA }) return { primary: ranked.slice(0, 2), extraCount: Math.max(0, ranked.length - 2), } }, [clusters]) // ─── 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) => { peek.open(noteId) } const visibleSuggestions = suggestionsShowAll ? suggestions.slice(0, 12) : suggestions.slice(0, SUGGESTIONS_PREVIEW) const handleCreateSuggestion = useCallback(async (s: BridgeSuggestion) => { const key = `${s.clusterAId}-${s.clusterBId}` setActingSuggestionKey(key) try { const html = `

${s.suggestedContent.replace(/

${s.justification.replace(/

` const note = await createNote({ title: s.suggestedTitle, content: html }) if (!note) throw new Error('create failed') emitNoteChange({ type: 'created', note }) await fetch('/api/bridge-notes', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }), }) setSuggestions(prev => prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId)) ) toast.success(t('insightsView.suggestions.created')) router.push(`/home?openNote=${note.id}`) } catch { toast.error(t('insightsView.suggestions.createError')) } finally { setActingSuggestionKey(null) } }, [router, t]) const handleDismissSuggestion = useCallback(async (s: BridgeSuggestion) => { const key = `${s.clusterAId}-${s.clusterBId}` setActingSuggestionKey(key) try { const res = await fetch('/api/bridge-notes', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ clusterAId: s.clusterAId, clusterBId: s.clusterBId }), }) if (!res.ok) throw new Error() setSuggestions(prev => prev.filter(x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId)) ) } catch { toast.error(t('insightsView.suggestions.dismissError')) } finally { setActingSuggestionKey(null) } }, [t]) const isRtl = locale === 'fa' || locale === 'ar' 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 (accordion + filtre + tri) */} {graphMode === 'list' && (
setListFilter(e.target.value)} placeholder={t('insightsView.listFilterPlaceholder')} aria-label={t('insightsView.listFilterPlaceholder')} className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70" />
{listClusters.length === 0 ? (

{t('insightsView.listFilterEmpty')}

) : ( listClusters.map(({ cluster, name, clusterNotes, matchingNotes, clusterBridges, nameMatches }) => { const isExpanded = expandedListClusterId === cluster.id const displayNotes = !listFilter.trim() || nameMatches ? clusterNotes : matchingNotes return (
{isExpanded && (
    {displayNotes.length === 0 ? (
  • {t('insightsView.clusters.emptyCluster')}
  • ) : ( displayNotes.map(note => (
  • )) )}
)}
) }) )}
)}
{/* ── Dashboard (droite) ── */}
{/* Filtre panneau droit (sticky) */}
{ setDashboardFilter(e.target.value) if (e.target.value.trim()) { setBridgesShowAll(true) setIsolatedShowAll(true) setDashboardSectionOpen(s => ({ ...s, bridges: true, isolated: true })) } }} placeholder={t('insightsView.dashboardFilterPlaceholder')} aria-label={t('insightsView.dashboardFilterPlaceholder')} className="w-full bg-white dark:bg-zinc-800/60 border border-border/40 rounded-xl ps-9 pe-3 py-2.5 text-xs outline-none focus:ring-2 focus:ring-ochre/30 focus:border-ochre/40 transition-all placeholder:text-concrete/70 shadow-sm" />
{/* 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.stats.themesSubtitle')}

{t('insightsView.stats.bridgeNotes')}
{bridgeNotes.length}

{t('insightsView.stats.bridgesSubtitle')}

{/* ③ Système de Recalcul (replié par défaut) */}
{dashboardSectionOpen.recalc && (
{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}` : '—'}
)}
{/* ④ Clusters Isolés */}
{dashboardSectionOpen.isolated && (

{t('insightsView.tipIsolatedAction')}

{visibleIsolatedClusters.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')} ))} {!dashboardFilter.trim() && filteredIsolatedClusters.length > DASHBOARD_PREVIEW && ( )} {filteredIsolatedClusters.length === 0 && (
{dashboardFilter.trim() ? t('insightsView.listFilterEmpty') : t('insightsView.isolatedClusters.empty')}
)}
)}
{/* ⑤ Notes-Ponts Influentes */}
{dashboardSectionOpen.bridges && (

{t('insightsView.tipBridgeNotes')}

{visibleBridgeList.map(bridge => { const { primary, extraCount } = getBridgeThemeSlice(bridge.clustersConnected) const themeA = clusters.find(c => c.id === String(primary[0])) const themeB = clusters.find(c => c.id === String(primary[1])) const nameA = themeA?.name || t('insightsView.clusterFallback', { index: primary[0] ?? 0 }) const nameB = themeB?.name || (primary[1] !== undefined ? t('insightsView.clusterFallback', { index: primary[1] }) : null) return ( 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.affinity', { score: (bridge.bridgeScore * 100).toFixed(0), })}
{nameB ? (
{extraCount > 0 && ( {t('insightsView.bridgeNotes.moreThemes', { count: extraCount })} )}
) : (

{t('insightsView.bridgeNotes.needsResync')}

)}
) })} {!dashboardFilter.trim() && filteredBridgeList.length > DASHBOARD_PREVIEW && ( )} {filteredBridgeList.length === 0 && !isCalculating && (
{dashboardFilter.trim() ? t('insightsView.listFilterEmpty') : t('insightsView.bridgeNotes.empty')}
)}
)}
{/* ⑥ Opportunités de Connexion (link prediction — top near-miss pairs) */}
{dashboardSectionOpen.suggestions && (

{t('insightsView.tipSuggestions')}

{visibleSuggestions.map(s => { const key = `${s.clusterAId}-${s.clusterBId}` const busy = actingSuggestionKey === key return (
{s.clusterAName} {s.clusterBName}

{s.suggestedTitle}

{s.suggestedContent}

) })} {!suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && ( )} {suggestionsShowAll && suggestions.length > SUGGESTIONS_PREVIEW && ( )} {isCalculating && (
{[1, 2].map(i => (
))}
)} {!isCalculating && suggestions.length === 0 && (
{t('insightsView.suggestions.emptyDescription')}
)}
)}
)} {/* ── Peek panel (factorisé) ── */} { router.push(`/home?openNote=${n.id}`); peek.close() }} />
) }