Files
Momento/memento-note/components/dashboard-view.tsx
Antigravity 3bdbec575a feat(ui): panneau repliable, carnets plus lisibles et accueil plus clair
Harmonise la liste des notes et l’éditeur (titres, dates, retour),
rend la saisie rapide et les raccourcis de l’accueil utilisables
sur téléphone, et conserve le panneau latéral repliable.
2026-09-05 20:53:48 +00:00

1199 lines
44 KiB
TypeScript

'use client'
import { useState, useEffect, useCallback, useMemo, useRef, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { useReducedMotion } from 'motion/react'
import { Send, Bell, Loader2, PenLine } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
import { redirectToAiConsentSettings } from '@/lib/consent/ai-consent-redirect'
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 { 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 { pathNoteTitle, pickFocusNote } from '@/lib/dashboard/path-title'
import { emitAiUsageChanged } from '@/lib/ai-usage-sync'
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
excerpt?: string
notebookId: string | null
updatedAt: string
notebook?: { id: string; name: string; color: string | null; icon: string | null } | null
}
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<string, number>
summary?: string
topTopic?: string
relatedNotes?: Array<{ id: string; title: string | null; notebookId: string | null }>
}
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, peekNoteId?: string | null) => void
}
interface GmailStatus {
connected: boolean
recentCaptures: number
}
const EMOTION_META: Record<string, { Icon: LucideIcon; color: string }> = {
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 | number>) => 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<string, string> = {
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 | null | undefined): string {
if (!html) return ''
return html.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/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
inboxPreview?: Array<{
id: string
title: string | null
excerpt?: string
notebookId: string | null
updatedAt?: string
}>
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<DashboardPath[]>([])
const [openLoops, setOpenLoops] = useState<DashboardOpenLoopItem[]>([])
const [pathsEnriching, setPathsEnriching] = useState(false)
const [flashcardStats, setFlashcardStats] = useState<{
retentionRate: number
streak: number
totalCards: number
} | null>(null)
const [sentiment, setSentiment] = useState<SentimentData | null>(null)
const [mindMap, setMindMap] = useState<MindMapData | null>(null)
const [mindMapLoading, setMindMapLoading] = useState(true)
const [sentimentLoading, setSentimentLoading] = useState(true)
const [captureText, setCaptureText] = useState('')
const [capturing, setCapturing] = useState(false)
const [captureError, setCaptureError] = useState(false)
const capturingLockRef = useRef(false)
const [inboxPulse, setInboxPulse] = useState(0)
const [actingSuggestionId, setActingSuggestionId] = useState<string | null>(null)
const [createdAgent, setCreatedAgent] = useState<{ id: string | null; topic: string } | null>(null)
const [echoRefreshing, setEchoRefreshing] = useState(false)
const [dismissingInsightId, setDismissingInsightId] = useState<string | null>(null)
const [actingBridgeSuggestionKey, setActingBridgeSuggestionKey] = useState<string | null>(null)
const [briefingError, setBriefingError] = useState(false)
const loadBriefing = useCallback(async () => {
try {
const res = await fetch('/api/briefing', { cache: 'no-store' })
if (res.ok) {
setData(await res.json())
setBriefingError(false)
} else {
setBriefingError(true)
}
} catch {
setBriefingError(true)
}
}, [])
const loadPaths = useCallback(async (briefing: NonNullable<typeof data>) => {
setPathsEnriching(true)
try {
const focus = pickFocusNote(briefing.recentNotes)
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)
}
}, [])
/** Recharge briefing puis enrichit les pistes (évite loadPaths() sans argument). */
const reloadBriefingAndPaths = useCallback(async () => {
try {
const res = await fetch('/api/briefing', { cache: 'no-store' })
if (!res.ok) {
setBriefingError(true)
return
}
const briefing = await res.json()
setData(briefing)
setBriefingError(false)
setPaths(buildFastPathsFromBriefing({
recentNotes: (briefing.recentNotes ?? []).map((n: BriefingNote) => ({
id: n.id,
title: n.title,
content: n.content,
notebookId: n.notebookId,
})),
inboxCount: briefing.inboxCount ?? 0,
dueFlashcards: briefing.dueFlashcards ?? 0,
insights: briefing.insights ?? [],
bridgeSuggestions: briefing.bridgeSuggestions ?? [],
agentSuggestions: briefing.agentSuggestions ?? [],
}))
await loadPaths(briefing)
} catch {
setBriefingError(true)
}
}, [loadPaths])
const loadSentiment = useCallback(async () => {
try {
const res = await fetch('/api/briefing/sentiment', { cache: 'no-store' })
if (res.ok) {
setSentiment(await res.json())
emitAiUsageChanged()
}
} 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 => {
setFlashcardStats({
retentionRate: json?.retentionRate ?? 0,
streak: json?.streak ?? 0,
totalCards: json?.totalCards ?? 0,
})
})
.catch(() => {
setFlashcardStats({ retentionRate: 0, streak: 0, totalCards: 0 })
})
}, [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 recentNotes = data?.recentNotes ?? []
const pathsList = useMemo(
() => paths.filter(p => p.type !== 'organize' && p.type !== 'review'),
[paths],
)
const pathBridgeKeys = useMemo(
() => pathsList
.filter(p => p.type === 'bridge' && p.clusterAId != null && p.clusterBId != null)
.map(p => `${p.clusterAId}-${p.clusterBId}`),
[pathsList],
)
const focusNote = useMemo(() => pickFocusNote(recentNotes), [recentNotes])
const openLoopsList = openLoops
const briefingLoading = data === null
const pathsLoading = data === null
const pathsDetailLoading = pathsEnriching
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.flatMap(n => {
const displayTitle = pathNoteTitle(n.title, n.content)
if (!displayTitle) return []
return [{
id: n.id,
title: displayTitle,
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 handleCapture = useCallback(async () => {
const text = captureText.trim()
if (!text || capturingLockRef.current) return
capturingLockRef.current = true
setCapturing(true)
setCaptureError(false)
try {
const words = text.split(/\s+/)
const title = words.slice(0, 5).join(' ') + (words.length > 5 ? '…' : '')
const note = await createNote({ title, content: `<p>${text.replace(/</g, '&lt;')}</p>` })
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 })
} else {
setCaptureError(true)
toast.error(t('homeDashboard.captureError'))
}
} catch {
setCaptureError(true)
toast.error(t('homeDashboard.captureError'))
} finally {
capturingLockRef.current = false
setCapturing(false)
}
}, [captureText, t])
const handleCaptureKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.nativeEvent.isComposing || e.key === 'Process') return
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
void 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,
peekNoteId?: string | null,
) => {
await markInsightViewed(insight.id)
const peek = peekNoteId && peekNoteId !== noteId ? peekNoteId : undefined
onNoteSelect(noteId, null, peek)
}, [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.code === 'ai_consent_required') {
redirectToAiConsentSettings(router)
return
}
if (!res.ok) throw new Error(json.error)
if (json.insight) {
await reloadBriefingAndPaths()
emitAiUsageChanged()
toast.success(t('homeDashboard.echoFound'))
} else {
toast.info(t('homeDashboard.echoNone'))
}
} catch {
toast.error(t('homeDashboard.echoFailed'))
} finally {
setEchoRefreshing(false)
}
}, [requestAiConsent, reloadBriefingAndPaths, t, router])
const handleEnableAi = useCallback(async () => {
await requestAiConsent()
await reloadBriefingAndPaths()
}, [requestAiConsent, reloadBriefingAndPaths])
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 = `<p>${s.suggestedContent.replace(/</g, '&lt;')}</p>`
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)
const topic = data?.agentSuggestions?.find(s => s.id === id)?.topic ?? ''
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)
setCreatedAgent({ id: json.agentId ?? null, topic })
toast.success(t('homeDashboard.agentCreated'))
} catch {
toast.error(t('homeDashboard.agentFailed'))
} finally {
setActingSuggestionId(null)
}
}, [t, data?.agentSuggestions])
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, path.note2Id)
break
case 'addLink':
if (path.noteId) onNoteSelect(path.noteId, path.notebookId ?? null, path.note2Id)
break
case 'openInsight': {
const insight = insights.find(i => i.id === path.insightId)
if (insight && path.noteId) handleOpenFromInsight(insight, path.noteId, path.note2Id)
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'),
// Coché quand il n'y a plus de piste de connexion à traiter
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) => (
<div id={`dashboard-widget-${id}`}>{node}</div>
)
switch (id) {
case 'capture':
return wrap(
<div
className={`rounded-xl border bg-white/80 dark:bg-zinc-900/80 backdrop-blur-sm shadow-sm h-full flex flex-col ${
captureError
? 'border-red-400/70 dark:border-red-500/60'
: 'border-border/30 focus-within:border-brand-accent/50'
} focus-within:ring-2 focus-within:ring-brand-accent focus-within:ring-offset-2 focus-within:ring-offset-background`}
>
<DashboardWidgetTitleRow
widgetId="capture"
icon={<PenLine size={16} className="text-brand-accent" />}
title={t('homeDashboard.quickCapture')}
className="mb-0 px-3 pt-3"
wrapTitle
/>
<p id="dashboard-capture-hint" className="px-3 pt-1 text-sm text-muted-foreground leading-snug">
{t('homeDashboard.captureGoesToFile')}
</p>
<div className="flex flex-col min-[360px]:flex-row items-stretch min-[360px]:items-end gap-2 px-3 pt-2 pb-3">
<textarea
value={captureText}
onChange={e => {
setCaptureText(e.target.value)
if (captureError) setCaptureError(false)
}}
onKeyDown={handleCaptureKeyDown}
placeholder={t('homeDashboard.quickCapturePlaceholder')}
rows={3}
dir="auto"
readOnly={capturing}
aria-busy={capturing}
aria-invalid={captureError}
aria-describedby={captureError ? 'dashboard-capture-status dashboard-capture-hint' : 'dashboard-capture-hint'}
className="min-h-16 min-w-0 flex-1 resize-none rounded-md bg-transparent px-1 py-2 text-base leading-snug text-foreground outline-none ring-0 shadow-none placeholder:text-foreground/55 read-only:opacity-70"
/>
<button
type="button"
onClick={() => void handleCapture()}
disabled={!captureText.trim() || capturing}
className={`flex h-11 w-11 shrink-0 self-end items-center justify-center rounded-lg bg-brand-accent text-white shadow-sm hover:bg-brand-accent/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-accent focus-visible:ring-offset-2 disabled:opacity-40 disabled:hover:bg-brand-accent ${
prefersReducedMotion ? '' : 'active:scale-95'
}`}
aria-busy={capturing}
aria-label={t('homeDashboard.captureSend')}
title={t('homeDashboard.captureSend')}
>
{capturing
? <Loader2 size={18} className="animate-spin" />
: <Send size={18} />}
</button>
</div>
<p
id="dashboard-capture-status"
role="status"
aria-live="polite"
className={`px-3 pb-3 text-sm leading-snug ${
captureError
? 'text-red-700 dark:text-red-400'
: capturing
? 'text-muted-foreground'
: 'sr-only'
}`}
>
{captureError
? t('homeDashboard.captureError')
: capturing
? t('homeDashboard.captureSending')
: ''}
</p>
</div>,
)
case 'next-paths':
return wrap(
<DashboardNextPaths
paths={pathsList}
loading={pathsLoading}
enriching={pathsEnriching}
focusNoteTitle={focusNote ? pathNoteTitle(focusNote.title, focusNote.content) : null}
onAction={handlePathAction}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'daily-review':
return wrap(
<DashboardDailyReview items={dailyReviewItems} loading={briefingLoading} />,
)
case 'open-loops':
return wrap(
<DashboardOpenLoops loops={openLoopsList} loading={pathsDetailLoading && openLoopsList.length === 0} onSelect={onNoteSelect} />,
)
case 'daily-note':
return wrap(
<DashboardDailyNoteWidget loading={briefingLoading} onOpen={handleOpenDailyNote} />,
)
case 'link-suggestions':
return wrap(
<DashboardLinkSuggestions
paths={linkSuggestionPaths}
loading={pathsDetailLoading && linkSuggestionPaths.length === 0}
onAction={(id) => {
const p = pathsList.find(x => x.id === id)
if (p) handlePathAction(p)
}}
/>,
)
case 'bridges':
return wrap(
<DashboardBridgesWidget
suggestions={(bridgeSuggestions ?? []).map(s => ({
clusterAId: s.clusterAId,
clusterBId: s.clusterBId,
clusterAName: s.clusterAName,
clusterBName: s.clusterBName,
suggestedTitle: s.suggestedTitle,
justification: s.justification,
}))}
loading={briefingLoading}
actingKey={actingBridgeSuggestionKey}
onCreate={handleCreateBridgeSuggestion}
onDismiss={(s) => handleDismissBridgeSuggestion(
bridgeSuggestions.find(b => b.clusterAId === s.clusterAId && b.clusterBId === s.clusterBId)!,
)}
/>,
)
case 'flashcards-progress':
return wrap(
<DashboardFlashcardsProgressWidget
retentionRate={flashcardStats?.retentionRate ?? 0}
streak={flashcardStats?.streak ?? 0}
totalCards={flashcardStats?.totalCards ?? 0}
dueCount={dueFlashcards}
loading={!flashcardStats}
onOpen={() => router.push('/revision')}
/>,
)
case 'resume':
return wrap(
<DashboardResumeHero
notes={resumeNotes}
loading={briefingLoading}
onSelect={onNoteSelect}
onCaptureFocus={() => {
document.getElementById('dashboard-widget-capture')?.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'center',
})
const ta = document.querySelector<HTMLTextAreaElement>('#dashboard-widget-capture textarea')
ta?.focus()
}}
formatRelativeTime={relTime}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'intelligence':
return wrap(
<IntelligenceHub
loading={briefingLoading}
aiActive={aiActive}
hasAiConsent={hasAiConsent}
providerReady={aiStatus?.providerReady ?? false}
memoryEchoEnabled={aiStatus?.memoryEchoEnabled ?? true}
insights={insights}
bridgeNotes={topBridgeNotes}
bridgeSuggestions={bridgeSuggestions}
agentActions={agentActions}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
onRefreshEcho={handleRefreshEcho}
onEnableAi={handleEnableAi}
echoRefreshing={echoRefreshing}
onDismissInsight={handleDismissInsight}
onDismissBridgeSuggestion={handleDismissBridgeSuggestion}
onCreateBridgeSuggestion={handleCreateBridgeSuggestion}
onOpenInsightNote={handleOpenFromInsight}
dismissingInsightId={dismissingInsightId}
actingBridgeSuggestionKey={actingBridgeSuggestionKey}
prefersReducedMotion={!!prefersReducedMotion}
excludeBridgeSuggestionKeys={pathBridgeKeys}
/>,
)
case 'reminders':
return wrap(
<div className="rounded-2xl border border-border/30 bg-white dark:bg-zinc-900 p-4">
<DashboardWidgetTitleRow
widgetId="reminders"
icon={<Bell size={12} className="text-brand-accent" />}
title={t('homeDashboard.reminders')}
/>
{briefingLoading ? (
<div className="space-y-2">
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
<div className="h-10 rounded-lg bg-stone-50 dark:bg-zinc-950/40 animate-pulse" />
</div>
) : reminders.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/35 bg-stone-50/50 dark:bg-zinc-950/30 p-3">
<p className="text-[11px] text-concrete leading-relaxed mb-2">
{t('homeDashboard.remindersEmpty')}
</p>
<button
type="button"
onClick={() => router.push('/home?reminders=1&forceList=1')}
className="text-[9px] font-mono font-bold uppercase text-brand-accent hover:underline"
>
{t('homeDashboard.remindersOpenAll')}
</button>
</div>
) : (
<div className="space-y-1.5">
{reminders.slice(0, 4).map(r => (
<button
key={r.id}
type="button"
onClick={() => onNoteSelect(r.id, r.notebookId)}
className="w-full flex items-center justify-between gap-2 p-2.5 rounded-xl border border-border/20 hover:border-brand-accent/25 hover:bg-brand-accent/[0.03] transition-all text-start group"
>
<span className="text-[11px] text-ink dark:text-dark-ink group-hover:text-brand-accent transition-colors truncate flex-1">
{r.title || t('homeDashboard.untitled')}
</span>
<span className="text-[8px] font-mono font-bold text-brand-accent bg-brand-accent/10 px-1.5 py-0.5 rounded shrink-0">
{r.reminder ? new Date(r.reminder).toLocaleDateString(dateLocale, { day: 'numeric', month: 'short' }) : ''}
</span>
</button>
))}
<button
type="button"
onClick={() => router.push('/home?reminders=1&forceList=1')}
className="w-full text-start px-1 pt-1 text-[9px] font-mono uppercase font-bold text-brand-accent hover:underline"
>
{t('homeDashboard.remindersOpenAll')}
</button>
</div>
)}
</div>,
)
case 'mind-map':
return wrap(
<DashboardMindOrbit
clusters={mindMap?.clusters ?? []}
bridgeNotes={mindMap?.bridgeNotes ?? []}
loading={mindMapLoading}
onOpenInsights={() => router.push('/insights')}
onOpenCluster={(clusterId) => router.push(`/insights?cluster=${clusterId}`)}
onNoteSelect={(nid) => onNoteSelect(nid, null)}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'agents':
return wrap(
<DashboardAgentCarousel
suggestions={agentSuggestions}
loading={briefingLoading}
actingId={actingSuggestionId}
formatFrequency={formatAgentFrequency}
onAccept={handleAcceptSuggestion}
onDismiss={handleDismissSuggestion}
createdAgent={createdAgent}
onOpenCreated={() => router.push(createdAgent?.id ? `/agents?id=${createdAgent.id}` : '/agents')}
onClearCreated={() => setCreatedAgent(null)}
prefersReducedMotion={!!prefersReducedMotion}
/>,
)
case 'sentiment':
return wrap(
<DashboardSentimentChip
available={!!sentiment?.available}
loading={sentimentLoading}
dominantEmotion={sentiment?.dominantEmotion}
summary={sentiment?.summary}
emotions={sentiment?.emotions}
emotionMeta={EMOTION_META}
relatedNotes={sentiment?.relatedNotes}
onSelectNote={onNoteSelect}
/>,
)
case 'inbox':
return wrap(
<DashboardInboxWidget
count={inboxCount}
notes={data?.inboxPreview ?? []}
loading={briefingLoading}
onOpen={() => router.push('/home?forceList=1')}
onSelect={onNoteSelect}
formatRelativeTime={relTime}
/>,
)
case 'revision':
return wrap(
<DashboardRevisionWidget
dueCount={dueFlashcards}
loading={briefingLoading}
onOpen={() => router.push('/revision')}
/>,
)
case 'stats':
return wrap(
<DashboardStatsWidget
clusterCount={themeCount}
bridgeCount={mindMap?.bridgeNotes.length ?? 0}
noteCount={mindMap?.totalNotes ?? 0}
loading={mindMapLoading}
onOpen={() => router.push('/insights')}
/>,
)
case 'agent-activity':
return wrap(
<DashboardAgentActivityWidget
actions={agentActions}
loading={briefingLoading}
onOpen={() => router.push('/agents')}
/>,
)
case 'gmail':
return wrap(
<DashboardGmailWidget
connected={!!gmail?.connected}
recentCaptures={gmail?.recentCaptures ?? 0}
loading={briefingLoading}
onOpen={() => router.push('/settings/integrations')}
/>,
)
case 'activity':
return wrap(
<DashboardActivityWidget
data={writingActivity}
loading={briefingLoading}
/>,
)
case 'pinned':
return wrap(
<DashboardPinnedWidget
notes={pinnedNotes}
loading={briefingLoading}
onSelect={onNoteSelect}
formatRelativeTime={relTime}
/>,
)
case 'usage':
return wrap(
<DashboardUsageWidget />,
)
default:
return null
}
}, [
t, captureText, capturing, captureError, handleCaptureKeyDown, handleCapture, resumeNotes, briefingLoading, pathsLoading, pathsEnriching, sentimentLoading,
onNoteSelect, relTime, prefersReducedMotion, mindMapLoading, aiActive, hasAiConsent,
aiStatus, insights, topBridgeNotes, bridgeSuggestions, agentActions, handleRefreshEcho,
handleEnableAi, echoRefreshing, handleDismissInsight, handleDismissBridgeSuggestion,
handleCreateBridgeSuggestion, handleOpenFromInsight, dismissingInsightId,
actingBridgeSuggestionKey, pathBridgeKeys, reminders, gmail, dateLocale, router, mindMap,
agentSuggestions, actingSuggestionId, createdAgent, formatAgentFrequency, handleAcceptSuggestion,
handleDismissSuggestion, sentiment, pinnedNotes, writingActivity, themeCount,
pathsList, openLoopsList, handlePathAction, dailyReviewItems, linkSuggestionPaths,
handleOpenDailyNote, flashcardStats, focusNote,
])
return (
<div className="h-full w-full bg-[#F9F8F6] dark:bg-[#0D0D0D] overflow-y-auto custom-scrollbar relative">
<div className="absolute inset-0 bg-[linear-gradient(to_right,#80808005_1px,transparent_1px),linear-gradient(to_bottom,#80808005_1px,transparent_1px)] bg-[size:28px_28px] pointer-events-none z-0" />
<div className="w-full px-4 sm:px-6 lg:px-8 xl:px-10 2xl:px-12 py-6 sm:py-8 relative z-10">
{briefingError && !data && (
<div className="mb-4 rounded-xl border border-rose-500/30 bg-rose-500/5 px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<p className="text-sm text-ink dark:text-dark-ink">
{t('homeDashboard.briefingLoadError')}
</p>
<button
type="button"
onClick={() => { void reloadBriefingAndPaths() }}
className="shrink-0 text-[10px] font-mono font-bold uppercase tracking-wider px-3 py-2 rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90"
>
{t('homeDashboard.briefingRetry')}
</button>
</div>
)}
{/* ── En-tête : orientation en 2 secondes ── */}
<header className="mb-5">
<div className="pb-4 border-b border-border/20">
<h1 className="font-serif text-2xl sm:text-3xl font-medium text-ink dark:text-dark-ink tracking-tight">
{t('homeDashboard.title')}
</h1>
<p className="text-[10px] font-mono uppercase tracking-[0.2em] text-concrete font-bold mt-1">
{new Date().toLocaleDateString(dateLocale, { weekday: 'long', day: 'numeric', month: 'long' })}
</p>
</div>
{/* Pulse : file d'attente cognitive en un coup d'œil */}
<div className="mt-4">
<DashboardActionStrip
inboxCount={inboxCount}
dueFlashcards={dueFlashcards}
reminderCount={reminders.length}
discoveryCount={discoveryCount}
themeCount={themeCount}
inboxPulse={inboxPulse}
onInbox={() => router.push('/home?forceList=1')}
onReview={() => router.push('/revision')}
onReminders={() => scrollToWidget('reminders')}
onDiscoveries={() => scrollToWidget('intelligence')}
onThemes={() => router.push('/insights')}
prefersReducedMotion={!!prefersReducedMotion}
/>
</div>
</header>
<div className="pb-8">
<DashboardWidgetGrid
renderWidget={renderWidget}
isWidgetEmpty={(id) => {
if (briefingLoading) return false
if (id === 'revision') return dueFlashcards === 0
return false
}}
/>
</div>
</div>
</div>
)
}