'use client' import { useState, useEffect, useCallback, useMemo, type ReactNode } from 'react' import { useRouter } from 'next/navigation' import { useReducedMotion } from 'motion/react' import { Inbox, Send, Bell, Mail } from 'lucide-react' import { useLanguage } from '@/lib/i18n' import { useAiConsent } from '@/components/legal/ai-consent-provider' import { createNote } from '@/app/actions/notes' import { emitNoteChange } from '@/lib/note-change-sync' import { toast } from 'sonner' import { IntelligenceHub } from '@/components/intelligence-hub' import { DashboardWidgetGrid } from '@/components/dashboard-widget-grid' import type { DashboardWidgetId } from '@/lib/dashboard/layout' import { DashboardWidgetHelp } from '@/components/dashboard-widget-help' import { DashboardWidgetTitleRow } from '@/components/dashboard-widget-title-row' import { DashboardActionStrip } from '@/components/dashboard-action-strip' import { DashboardResumeHero } from '@/components/dashboard-resume-hero' import { DashboardMindOrbit } from '@/components/dashboard-mind-orbit' import { DashboardAgentCarousel } from '@/components/dashboard-agent-carousel' import { DashboardSentimentChip } from '@/components/dashboard-sentiment-chip' import { DashboardNextPaths } from '@/components/dashboard-next-paths' import { DashboardDailyReview, DashboardOpenLoops, DashboardDailyNoteWidget, DashboardLinkSuggestions, DashboardBridgesWidget, } from '@/components/dashboard-path-widgets' import type { DashboardPath } from '@/lib/dashboard/path-types' import { buildFastPathsFromBriefing } from '@/lib/dashboard/paths-fast' import { DashboardInboxWidget, DashboardRevisionWidget, DashboardStatsWidget, DashboardAgentActivityWidget, DashboardGmailWidget, DashboardPinnedWidget, DashboardActivityWidget, DashboardUsageWidget, DashboardFlashcardsProgressWidget, } from '@/components/dashboard-catalog-widgets' import { ScanEye, Search, Zap, CloudRain, Waves, HeartPulse, Lightbulb, Brain, } from 'lucide-react' import type { LucideIcon } from 'lucide-react' interface BriefingPinnedNote { id: string title: string | null notebookId: string | null updatedAt: string } interface ActivityDay { date: string count: number } interface DashboardOpenLoopItem { id: string title: string | null notebookId: string | null daysStale: number } interface BriefingNote { id: string title: string | null content: string color: string notebookId: string | null updatedAt: string notebook: { id: string; name: string; color: string | null; icon: string | null } | null } interface BriefingInsight { id: string insight: string score: number date: string viewed: boolean note1: { id: string; title: string | null } note2: { id: string; title: string | null } note1Excerpt?: string note2Excerpt?: string } interface BridgeSuggestionItem { clusterAId: number clusterBId: number clusterAName: string clusterBName: string suggestedTitle: string suggestedContent: string justification: string } interface BriefingAiStatus { consent: boolean memoryEchoEnabled: boolean providerReady: boolean active: boolean } interface BriefingAgentAction { id: string agentName: string agentType: string result: string | null createdAt: string } interface AgentSuggestionItem { id: string topic: string reason: string suggestedType: string suggestedFrequency: string relatedNoteCount: number clusterId: number | null } interface BriefingReminder { id: string title: string reminder: string | null notebookId: string | null } interface SentimentData { available: boolean dominantEmotion?: string sentimentScore?: number emotions?: Record summary?: string topTopic?: string } interface MindMapData { clusters: { clusterId: number; name?: string; noteIds: string[] }[] bridgeNotes: { noteId: string bridgeScore: number clusterNames?: string[] note?: { id: string; title: string | null; content?: string } }[] cached: boolean totalNotes: number } interface DashboardViewProps { onNoteSelect: (noteId: string, notebookId: string | null) => void } interface GmailStatus { connected: boolean recentCaptures: number } const EMOTION_META: Record = { focused: { Icon: ScanEye, color: '#D97706' }, curious: { Icon: Search, color: '#0891B2' }, enthusiastic: { Icon: Zap, color: '#EA580C' }, frustrated: { Icon: CloudRain, color: '#DC2626' }, calm: { Icon: Waves, color: '#059669' }, anxious: { Icon: HeartPulse, color: '#7C3AED' }, creative: { Icon: Lightbulb, color: '#DB2777' }, reflective: { Icon: Brain, color: '#4F46E5' }, } function formatRelativeTime( dateStr: string, t: (key: string, params?: Record) => string, ): string { const diff = Date.now() - new Date(dateStr).getTime() const mins = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) const days = Math.floor(diff / 86400000) if (mins < 1) return t('time.justNow') if (mins < 60) return t('time.minutesAgo', { count: mins }) if (hours < 24) return t('time.hoursAgo', { count: hours }) return t('time.daysAgo', { count: days }) } function localeForLanguage(language: string): string { const map: Record = { en: 'en-US', fr: 'fr-FR', es: 'es-ES', de: 'de-DE', it: 'it-IT', pt: 'pt-PT', nl: 'nl-NL', pl: 'pl-PL', ru: 'ru-RU', zh: 'zh-CN', ja: 'ja-JP', ko: 'ko-KR', ar: 'ar-SA', fa: 'fa-IR', hi: 'hi-IN', } return map[language] || language } function stripHtml(html: string): string { return html.replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/\s+/g, ' ').trim() } // ─── Main ───────────────────────────────────────────── export function DashboardView({ onNoteSelect }: DashboardViewProps) { const router = useRouter() const { t, language } = useLanguage() const { hasAiConsent, requestAiConsent } = useAiConsent() const prefersReducedMotion = useReducedMotion() const scrollToWidget = (id: string) => { document.getElementById(`dashboard-widget-${id}`)?.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start', }) } const [data, setData] = useState<{ recentNotes: BriefingNote[] inboxCount: number dueFlashcards: number upcomingReminders: BriefingReminder[] insights: BriefingInsight[] agentActions: BriefingAgentAction[] agentSuggestions?: AgentSuggestionItem[] bridgeSuggestions?: BridgeSuggestionItem[] gmail?: GmailStatus ai?: BriefingAiStatus pinnedNotes?: BriefingPinnedNote[] writingActivity?: ActivityDay[] } | null>(null) const [paths, setPaths] = useState([]) const [openLoops, setOpenLoops] = useState([]) const [pathsEnriching, setPathsEnriching] = useState(false) const [flashcardStats, setFlashcardStats] = useState<{ retentionRate: number streak: number totalCards: number } | null>(null) const [sentiment, setSentiment] = useState(null) const [mindMap, setMindMap] = useState(null) const [mindMapLoading, setMindMapLoading] = useState(true) const [sentimentLoading, setSentimentLoading] = useState(true) const [captureText, setCaptureText] = useState('') const [capturing, setCapturing] = useState(false) const [inboxPulse, setInboxPulse] = useState(0) const [actingSuggestionId, setActingSuggestionId] = useState(null) const [echoRefreshing, setEchoRefreshing] = useState(false) const [dismissingInsightId, setDismissingInsightId] = useState(null) const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState(null) const loadBriefing = useCallback(async () => { try { const res = await fetch('/api/briefing', { cache: 'no-store' }) if (res.ok) setData(await res.json()) } catch { /* état dégradé */ } }, []) const loadPaths = useCallback(async (briefing: NonNullable) => { setPathsEnriching(true) try { const focus = briefing.recentNotes[0] const res = await fetch('/api/briefing/paths', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ recentNote: focus ? { id: focus.id, title: focus.title, content: focus.content, notebookId: focus.notebookId, } : null, inboxCount: briefing.inboxCount, dueFlashcards: briefing.dueFlashcards, insights: briefing.insights, bridgeSuggestions: briefing.bridgeSuggestions ?? [], agentSuggestions: briefing.agentSuggestions ?? [], }), cache: 'no-store', }) if (res.ok) { const json = await res.json() setPaths(json.paths ?? []) setOpenLoops(json.openLoops ?? []) } } catch { /* pistes enrichies optionnelles — les pistes rapides restent affichées */ } finally { setPathsEnriching(false) } }, []) const loadSentiment = useCallback(async () => { try { const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' }) if (res.ok) setSentiment(await res.json()) } catch { /* widget sentiment en état vide */ } finally { setSentimentLoading(false) } }, []) const loadMindMap = useCallback(async () => { try { const res = await fetch('/api/clusters?lite=1', { cache: 'no-store' }) if (res.ok) { const json = await res.json() setMindMap({ clusters: json.clusters || [], bridgeNotes: json.bridgeNotes || [], cached: !!json.cached, totalNotes: json.totalNotes || 0, }) } } catch {} finally { setMindMapLoading(false) } }, []) useEffect(() => { loadBriefing() loadMindMap() fetch('/api/flashcards/stats', { cache: 'no-store' }) .then(r => r.ok ? r.json() : null) .then(json => { if (json) { setFlashcardStats({ retentionRate: json.retentionRate ?? 0, streak: json.streak ?? 0, totalCards: json.totalCards ?? 0, }) } }) .catch(() => {}) }, [loadBriefing, loadMindMap]) // Pistes rapides dès le briefing, puis enrichissement en arrière-plan useEffect(() => { if (!data) return setPaths(buildFastPathsFromBriefing({ recentNotes: data.recentNotes.map(n => ({ id: n.id, title: n.title, content: n.content, notebookId: n.notebookId, })), inboxCount: data.inboxCount, dueFlashcards: data.dueFlashcards, insights: data.insights, bridgeSuggestions: data.bridgeSuggestions ?? [], agentSuggestions: data.agentSuggestions ?? [], })) void loadPaths(data) }, [data, loadPaths]) // Sentiment (LLM) en arrière-plan — ne bloque pas le reste du tableau de bord useEffect(() => { const run = () => { void loadSentiment() } if (typeof requestIdleCallback === 'function') { const id = requestIdleCallback(run, { timeout: 2500 }) return () => cancelIdleCallback(id) } const t = setTimeout(run, 400) return () => clearTimeout(t) }, [loadSentiment]) const aiStatus = data?.ai const aiActive = aiStatus?.active ?? false const inboxCount = data?.inboxCount ?? 0 const dueFlashcards = data?.dueFlashcards ?? 0 const reminders = data?.upcomingReminders ?? [] const insights = data?.insights ?? [] const agentActions = data?.agentActions ?? [] const agentSuggestions = data?.agentSuggestions ?? [] const bridgeSuggestions = data?.bridgeSuggestions ?? [] const gmail = data?.gmail const pinnedNotes = data?.pinnedNotes ?? [] const writingActivity = data?.writingActivity ?? [] const pathsList = paths const openLoopsList = openLoops const briefingLoading = data === null const pathsLoading = data === null const pathsDetailLoading = pathsEnriching const recentNotes = data?.recentNotes ?? [] const topBridgeNotes = useMemo(() => (mindMap?.bridgeNotes ?? []).slice(0, 3), [mindMap?.bridgeNotes]) const dateLocale = localeForLanguage(language) const discoveryCount = useMemo(() => { const fresh = insights.filter(i => !i.viewed).length return fresh + bridgeSuggestions.length + agentActions.length }, [insights, bridgeSuggestions, agentActions]) const themeCount = mindMap?.clusters.length ?? 0 const resumeNotes = useMemo(() => recentNotes.map(n => ({ id: n.id, title: n.title, excerpt: stripHtml(n.content).slice(0, 180), notebookName: n.notebook?.name || t('homeDashboard.inbox'), notebookColor: n.notebook?.color || '#8B5CF6', updatedAt: n.updatedAt, notebookId: n.notebookId, })), [recentNotes, t]) const briefingSubtitle = useMemo(() => { if (briefingLoading) return '' const parts: string[] = [] if (inboxCount > 0) parts.push(t('homeDashboard.pulseInbox', { count: inboxCount })) if (dueFlashcards > 0) parts.push(t('homeDashboard.pulseReview', { count: dueFlashcards })) if (discoveryCount > 0) parts.push(t('homeDashboard.pulseDiscoveries', { count: discoveryCount })) if (parts.length === 0) return t('homeDashboard.pulseClear') return parts.join(' · ') }, [briefingLoading, inboxCount, dueFlashcards, discoveryCount, t]) const handleCapture = useCallback(async () => { const text = captureText.trim() if (!text) return setCapturing(true) try { const words = text.split(/\s+/) const title = words.slice(0, 5).join(' ') + (words.length > 5 ? '…' : '') const note = await createNote({ title, content: `

${text.replace(/` }) if (note) { emitNoteChange({ type: 'created', note }) setCaptureText('') setInboxPulse(p => p + 1) setData(prev => prev ? { ...prev, inboxCount: prev.inboxCount + 1 } : prev) toast.success(t('homeDashboard.captured'), { duration: 2000 }) } } catch { toast.error(t('homeDashboard.captureError')) } finally { setCapturing(false) } }, [captureText, t]) const handleCaptureKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleCapture() } } const markInsightViewed = useCallback(async (insightId: string) => { await fetch('/api/ai/echo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'view', insightId }), }).catch(() => {}) setData(prev => prev ? { ...prev, insights: prev.insights.map(i => i.id === insightId ? { ...i, viewed: true } : i), } : prev) }, []) const handleOpenFromInsight = useCallback(async (insight: BriefingInsight, noteId: string) => { await markInsightViewed(insight.id) onNoteSelect(noteId, null) }, [markInsightViewed, onNoteSelect]) const handleDismissInsight = useCallback(async (insight: BriefingInsight) => { setDismissingInsightId(insight.id) try { const res = await fetch('/api/ai/echo/dismiss', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ noteId: insight.note1.id, connectedNoteId: insight.note2.id }), }) if (!res.ok) throw new Error() setData(prev => prev ? { ...prev, insights: prev.insights.filter(i => i.id !== insight.id) } : prev) } catch { toast.error(t('homeDashboard.captureError')) } finally { setDismissingInsightId(null) } }, [t]) const handleRefreshEcho = useCallback(async () => { const consented = await requestAiConsent() if (!consented) return setEchoRefreshing(true) try { const res = await fetch('/api/ai/echo') const json = await res.json() if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return } if (!res.ok) throw new Error(json.error) if (json.insight) { await Promise.all([loadBriefing(), loadPaths()]) toast.success(t('homeDashboard.echoFound')) } else { toast.info(t('homeDashboard.echoNone')) } } catch { toast.error(t('homeDashboard.echoFailed')) } finally { setEchoRefreshing(false) } }, [requestAiConsent, loadBriefing, loadPaths, t]) const handleEnableAi = useCallback(async () => { await requestAiConsent() await Promise.all([loadBriefing(), loadPaths()]) }, [requestAiConsent, loadBriefing, loadPaths]) const handleDismissBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => { const key = `${s.clusterAId}-${s.clusterBId}` setActingBridgeSuggestionKey(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() setData(prev => prev ? { ...prev, bridgeSuggestions: prev.bridgeSuggestions?.filter( x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId), ) ?? [], } : prev) } catch { toast.error(t('homeDashboard.captureError')) } finally { setActingBridgeSuggestionKey(null) } }, [t]) const handleCreateBridgeSuggestion = useCallback(async (s: BridgeSuggestionItem) => { const key = `${s.clusterAId}-${s.clusterBId}` setActingBridgeSuggestionKey(key) try { const html = `

${s.suggestedContent.replace(/` const note = await createNote({ title: s.suggestedTitle, content: html }) if (!note) throw new Error() 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 }), }).catch(() => {}) setData(prev => prev ? { ...prev, bridgeSuggestions: prev.bridgeSuggestions?.filter( x => !(x.clusterAId === s.clusterAId && x.clusterBId === s.clusterBId), ) ?? [], } : prev) toast.success(t('homeDashboard.bridgeNoteCreated')) onNoteSelect(note.id, note.notebookId ?? null) } catch { toast.error(t('homeDashboard.captureError')) } finally { setActingBridgeSuggestionKey(null) } }, [onNoteSelect, t]) const formatAgentFrequency = useCallback((frequency: string) => { const key = `agents.frequencies.${frequency.toLowerCase()}` const label = t(key) return label !== key ? label : frequency }, [t]) const handleAcceptSuggestion = useCallback(async (id: string) => { setActingSuggestionId(id) try { const res = await fetch(`/api/agents/suggestions/${id}/accept`, { method: 'POST' }) const json = await res.json() if (!res.ok) throw new Error(json.error) setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev) toast.success(t('homeDashboard.agentCreated')) if (json.agentId) router.push(`/agents?id=${json.agentId}`) } catch { toast.error(t('homeDashboard.agentFailed')) } finally { setActingSuggestionId(null) } }, [t, router]) const handleDismissSuggestion = useCallback(async (id: string) => { setActingSuggestionId(id) try { const res = await fetch(`/api/agents/suggestions/${id}/dismiss`, { method: 'POST' }) if (!res.ok) throw new Error() setData(prev => prev ? { ...prev, agentSuggestions: prev.agentSuggestions?.filter(s => s.id !== id) ?? [] } : prev) } catch { toast.error(t('homeDashboard.captureError')) } finally { setActingSuggestionId(null) } }, [t]) const handlePathAction = useCallback((path: DashboardPath) => { switch (path.actionKey) { case 'continue': if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) break case 'compare': if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null) if (path.note2Id) toast.info(t('homeDashboard.pathCompareHint', { title: path.title })) break case 'addLink': if (path.noteId) { onNoteSelect(path.noteId, path.notebookId ?? null) toast.info(t('homeDashboard.pathAddLinkHint', { link: path.title })) } break case 'openInsight': { const insight = insights.find(i => i.id === path.insightId) if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId) break } case 'createBridge': { const bridge = bridgeSuggestions.find( b => b.clusterAId === path.clusterAId && b.clusterBId === path.clusterBId, ) if (bridge) handleCreateBridgeSuggestion(bridge) break } case 'createAgent': if (path.agentSuggestionId) handleAcceptSuggestion(path.agentSuggestionId) break case 'exploreTheme': router.push('/insights') break case 'organizeInbox': router.push('/home?forceList=1') break case 'reviewCards': router.push('/revision') break case 'openDaily': fetch('/api/notes/daily') .then(r => r.ok ? r.json() : null) .then(json => { if (json?.note?.id) onNoteSelect(json.note.id, json.note.notebookId ?? null) }) .catch(() => toast.error(t('homeDashboard.captureError'))) break default: break } }, [ onNoteSelect, t, insights, handleOpenFromInsight, bridgeSuggestions, handleCreateBridgeSuggestion, handleAcceptSuggestion, router, ]) const handleOpenDailyNote = useCallback(() => { fetch('/api/notes/daily') .then(r => r.ok ? r.json() : null) .then(json => { if (json?.note?.id) onNoteSelect(json.note.id, json.note.notebookId ?? null) }) .catch(() => toast.error(t('homeDashboard.captureError'))) }, [onNoteSelect, t]) const dailyReviewItems = useMemo(() => [ { key: 'inbox', label: t('homeDashboard.dailyReviewInbox'), done: inboxCount === 0, count: inboxCount, onClick: () => router.push('/home?forceList=1'), }, { key: 'discoveries', label: t('homeDashboard.dailyReviewDiscoveries'), done: discoveryCount === 0, count: discoveryCount, onClick: () => scrollToWidget('intelligence'), }, { key: 'connection', label: t('homeDashboard.dailyReviewConnection'), done: pathsList.some(p => p.type === 'connect' || p.type === 'resurface'), onClick: () => { const p = pathsList.find(x => x.type === 'connect' || x.type === 'resurface') if (p) handlePathAction(p) else scrollToWidget('next-paths') }, }, { key: 'review', label: t('homeDashboard.dailyReviewCards'), done: dueFlashcards === 0, count: dueFlashcards, onClick: () => router.push('/revision'), }, ], [t, inboxCount, discoveryCount, dueFlashcards, pathsList, router, handlePathAction]) const linkSuggestionPaths = useMemo( () => pathsList.filter(p => p.type === 'add-link').map(p => ({ id: p.id, title: p.title, description: p.description, score: p.score, })), [pathsList], ) const relTime = useCallback((d: string) => formatRelativeTime(d, t), [t]) const renderWidget = useCallback((id: DashboardWidgetId) => { const wrap = (node: ReactNode) => (

{node}
) switch (id) { case 'capture': return wrap(
{t('homeDashboard.quickCapture')}