'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' && (
{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) */}
{/* 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 && (
)}
{!isCalculating && suggestions.length === 0 && (
{t('insightsView.suggestions.emptyDescription')}
)}
)}
)}
{/* ── Peek panel (factorisé) ── */}
{ router.push(`/home?openNote=${n.id}`); peek.close() }}
/>
)
}