feat(dashboard): tableau de bord Second Brain configurable avec chargement progressif
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m3s
CI / Deploy production (on server) (push) Successful in 23s

Briefing granulaire, pistes rapides puis enrichissement async, layout persisté v5,
suggestions agents, intégration Gmail et navigation sidebar alignée sur /home.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antigravity
2026-07-14 16:50:53 +00:00
parent d38a99586b
commit 30da592ba2
62 changed files with 7741 additions and 335 deletions

View File

@@ -0,0 +1,17 @@
/** True when /home shows the Second Brain dashboard (not list, editor, or filters). */
export function isDashboardHomeRoute(
pathname: string,
searchParams: Pick<URLSearchParams, 'get'>,
): boolean {
if (pathname !== '/home') return false
return (
searchParams.get('forceList') !== '1' &&
!searchParams.get('notebook') &&
!searchParams.get('search') &&
!searchParams.get('labels') &&
!searchParams.get('shared') &&
!searchParams.get('reminders') &&
!searchParams.get('color') &&
!searchParams.get('openNote')
)
}

View File

@@ -0,0 +1,297 @@
export const DASHBOARD_LAYOUT_VERSION = 5 as const
/** Widgets visibles dans la mise en page par défaut (réf. prototype / capture utilisateur). */
export const CANONICAL_VISIBLE_WIDGET_IDS: readonly DashboardWidgetId[] = [
'capture',
'next-paths',
'intelligence',
'resume',
'mind-map',
'sentiment',
'inbox',
'revision',
'stats',
'reminders',
'flashcards-progress',
'agents',
] as const
export type DashboardWidgetId =
| 'capture'
| 'next-paths'
| 'resume'
| 'intelligence'
| 'reminders'
| 'mind-map'
| 'agents'
| 'sentiment'
| 'inbox'
| 'revision'
| 'stats'
| 'agent-activity'
| 'gmail'
| 'activity'
| 'pinned'
| 'usage'
| 'daily-review'
| 'open-loops'
| 'daily-note'
| 'link-suggestions'
| 'bridges'
| 'flashcards-progress'
export type DashboardWidgetZone = 'full' | 'main' | 'side'
export type DashboardWidgetCategory =
| 'essential'
| 'paths'
| 'cognition'
| 'productivity'
| 'agents'
export interface DashboardWidgetPlacement {
id: DashboardWidgetId
visible: boolean
order: number
zone: DashboardWidgetZone
}
export interface DashboardLayout {
version: typeof DASHBOARD_LAYOUT_VERSION
widgets: DashboardWidgetPlacement[]
}
export const DASHBOARD_WIDGET_META: Record<DashboardWidgetId, {
required: boolean
zone: DashboardWidgetZone
category: DashboardWidgetCategory
defaultVisible: boolean
}> = {
capture: { required: true, zone: 'full', category: 'essential', defaultVisible: true },
'next-paths': { required: false, zone: 'main', category: 'paths', defaultVisible: true },
resume: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
intelligence: { required: true, zone: 'main', category: 'essential', defaultVisible: true },
reminders: { required: false, zone: 'side', category: 'productivity', defaultVisible: true },
'mind-map': { required: false, zone: 'main', category: 'cognition', defaultVisible: true },
agents: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
sentiment: { required: false, zone: 'side', category: 'agents', defaultVisible: true },
inbox: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
revision: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
stats: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
'agent-activity': { required: false, zone: 'side', category: 'agents', defaultVisible: false },
gmail: { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
activity: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
pinned: { required: false, zone: 'side', category: 'cognition', defaultVisible: false },
usage: { required: false, zone: 'side', category: 'agents', defaultVisible: false },
'daily-review': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'open-loops': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'daily-note': { required: false, zone: 'side', category: 'paths', defaultVisible: false },
'link-suggestions': { required: false, zone: 'main', category: 'paths', defaultVisible: false },
bridges: { required: false, zone: 'main', category: 'cognition', defaultVisible: false },
'flashcards-progress': { required: false, zone: 'side', category: 'productivity', defaultVisible: false },
}
export const DASHBOARD_CATEGORY_ORDER: DashboardWidgetCategory[] = [
'essential',
'paths',
'cognition',
'productivity',
'agents',
]
export const DEFAULT_DASHBOARD_LAYOUT: DashboardLayout = {
version: DASHBOARD_LAYOUT_VERSION,
widgets: [
// Pleine largeur
{ id: 'capture', visible: true, order: 0, zone: 'full' },
// Colonne principale (gauche) — ordre vertical
{ id: 'next-paths', visible: true, order: 1, zone: 'main' },
{ id: 'intelligence', visible: true, order: 2, zone: 'main' },
{ id: 'resume', visible: true, order: 3, zone: 'main' },
{ id: 'mind-map', visible: true, order: 4, zone: 'main' },
// Colonne latérale (droite) — cartes compactes + widgets IA
{ id: 'sentiment', visible: true, order: 5, zone: 'side' },
{ id: 'inbox', visible: true, order: 6, zone: 'side' },
{ id: 'revision', visible: true, order: 7, zone: 'side' },
{ id: 'stats', visible: true, order: 8, zone: 'side' },
{ id: 'reminders', visible: true, order: 9, zone: 'side' },
{ id: 'flashcards-progress', visible: true, order: 10, zone: 'side' },
{ id: 'agents', visible: true, order: 11, zone: 'side' },
// Catalogue — masqués par défaut, ajoutables via « Personnaliser »
{ id: 'daily-review', visible: false, order: 12, zone: 'side' },
{ id: 'agent-activity', visible: false, order: 13, zone: 'side' },
{ id: 'gmail', visible: false, order: 14, zone: 'side' },
{ id: 'activity', visible: false, order: 15, zone: 'main' },
{ id: 'pinned', visible: false, order: 16, zone: 'side' },
{ id: 'usage', visible: false, order: 17, zone: 'side' },
{ id: 'open-loops', visible: false, order: 18, zone: 'side' },
{ id: 'daily-note', visible: false, order: 19, zone: 'side' },
{ id: 'link-suggestions', visible: false, order: 20, zone: 'main' },
{ id: 'bridges', visible: false, order: 21, zone: 'main' },
],
}
/** Clone frais — évite de muter le constant partagé via useState. */
export function getDefaultDashboardLayout(): DashboardLayout {
return structuredClone(DEFAULT_DASHBOARD_LAYOUT)
}
export function resetDashboardLayout(): DashboardLayout {
return getDefaultDashboardLayout()
}
const ALL_WIDGET_IDS = Object.keys(DASHBOARD_WIDGET_META) as DashboardWidgetId[]
const LEGACY_SPAN_ZONE: Record<string, DashboardWidgetZone> = {
full: 'full',
large: 'main',
medium: 'main',
small: 'side',
compact: 'side',
}
function isValidZone(zone: unknown): zone is DashboardWidgetZone {
return zone === 'full' || zone === 'main' || zone === 'side'
}
function legacyZoneFromItem(item: Record<string, unknown>, id: DashboardWidgetId): DashboardWidgetZone {
if (isValidZone(item.zone)) return item.zone
const span = item.span
if (typeof span === 'string' && LEGACY_SPAN_ZONE[span]) return LEGACY_SPAN_ZONE[span]
return DASHBOARD_WIDGET_META[id].zone
}
export function normalizeDashboardLayout(raw: unknown): DashboardLayout {
if (!raw || typeof raw !== 'object') return getDefaultDashboardLayout()
const data = raw as Partial<DashboardLayout> & { version?: number }
if (!Array.isArray(data.widgets) || data.widgets.length === 0) return getDefaultDashboardLayout()
// Layout périmé ou vide → preset canonique
const incomingVersion = typeof data.version === 'number' ? data.version : 0
if (incomingVersion < DASHBOARD_LAYOUT_VERSION) {
return getDefaultDashboardLayout()
}
const byId = new Map<DashboardWidgetId, DashboardWidgetPlacement>()
for (const item of data.widgets) {
if (!item || typeof item !== 'object') continue
const id = (item as DashboardWidgetPlacement).id
if (!ALL_WIDGET_IDS.includes(id)) continue
const meta = DASHBOARD_WIDGET_META[id]
byId.set(id, {
id,
visible: meta.required ? true : Boolean((item as DashboardWidgetPlacement).visible),
order: Number.isFinite((item as DashboardWidgetPlacement).order)
? Number((item as DashboardWidgetPlacement).order)
: 0,
zone: legacyZoneFromItem(item as unknown as Record<string, unknown>, id),
})
}
for (const id of ALL_WIDGET_IDS) {
if (!byId.has(id)) {
const meta = DASHBOARD_WIDGET_META[id]
const fallback = DEFAULT_DASHBOARD_LAYOUT.widgets.find(w => w.id === id)
byId.set(id, {
id,
visible: meta.defaultVisible,
order: fallback?.order ?? 99,
zone: meta.zone,
})
}
}
const widgets = [...byId.values()]
.sort((a, b) => a.order - b.order)
.map((w, index) => ({ ...w, order: index }))
const layout = { version: DASHBOARD_LAYOUT_VERSION, widgets }
const visibleCount = layout.widgets.filter(w => w.visible).length
if (visibleCount === 0) return getDefaultDashboardLayout()
return layout
}
export function reorderWidgets(
layout: DashboardLayout,
activeId: DashboardWidgetId,
overId: DashboardWidgetId,
): DashboardLayout {
const activeMeta = DASHBOARD_WIDGET_META[activeId]
const overMeta = DASHBOARD_WIDGET_META[overId]
if (activeMeta.zone !== overMeta.zone) return layout
const visible = layout.widgets
.filter(w => w.visible && w.zone === activeMeta.zone)
.sort((a, b) => a.order - b.order)
const activeIdx = visible.findIndex(w => w.id === activeId)
const overIdx = visible.findIndex(w => w.id === overId)
if (activeIdx === -1 || overIdx === -1 || activeIdx === overIdx) return layout
const next = [...visible]
const [moved] = next.splice(activeIdx, 1)
next.splice(overIdx, 0, moved)
const orderMap = new Map(next.map((w, i) => [w.id, i]))
return {
...layout,
widgets: layout.widgets.map(w => {
if (w.zone !== activeMeta.zone || !w.visible) return w
return { ...w, order: orderMap.has(w.id) ? orderMap.get(w.id)! : w.order + next.length }
}),
}
}
export function setWidgetVisibility(
layout: DashboardLayout,
id: DashboardWidgetId,
visible: boolean,
): DashboardLayout {
if (DASHBOARD_WIDGET_META[id].required && !visible) return layout
return {
...layout,
widgets: layout.widgets.map(w => w.id === id ? { ...w, visible } : w),
}
}
export function visibleWidgets(layout: DashboardLayout): DashboardWidgetPlacement[] {
return layout.widgets.filter(w => w.visible).sort((a, b) => a.order - b.order)
}
export function visibleWidgetsInZone(
layout: DashboardLayout,
zone: DashboardWidgetZone,
): DashboardWidgetPlacement[] {
return visibleWidgets(layout).filter(w => w.zone === zone)
}
export function hiddenWidgetIds(layout: DashboardLayout): DashboardWidgetId[] {
return layout.widgets.filter(w => !w.visible).map(w => w.id)
}
export function catalogByCategory(layout: DashboardLayout): Record<DashboardWidgetCategory, DashboardWidgetId[]> {
const result = Object.fromEntries(
DASHBOARD_CATEGORY_ORDER.map(c => [c, [] as DashboardWidgetId[]]),
) as Record<DashboardWidgetCategory, DashboardWidgetId[]>
for (const id of ALL_WIDGET_IDS) {
result[DASHBOARD_WIDGET_META[id].category].push(id)
}
for (const cat of DASHBOARD_CATEGORY_ORDER) {
result[cat].sort((a, b) => {
const aVis = layout.widgets.find(w => w.id === a)?.visible ? 0 : 1
const bVis = layout.widgets.find(w => w.id === b)?.visible ? 0 : 1
if (aVis !== bVis) return aVis - bVis
return a.localeCompare(b)
})
}
return result
}
export function isWidgetVisible(layout: DashboardLayout, id: DashboardWidgetId): boolean {
return layout.widgets.find(w => w.id === id)?.visible ?? DASHBOARD_WIDGET_META[id].defaultVisible
}

View File

@@ -0,0 +1,36 @@
export type DashboardPathType =
| 'continue'
| 'connect'
| 'bridge'
| 'research'
| 'organize'
| 'explore'
| 'review'
| 'resurface'
| 'daily'
| 'add-link'
export interface DashboardPath {
id: string
type: DashboardPathType
priority: number
title: string
description: string
actionKey: string
noteId?: string
note2Id?: string
notebookId?: string
clusterAId?: number
clusterBId?: number
agentSuggestionId?: string
insightId?: string
snippet?: string
score?: number
}
export interface DashboardOpenLoop {
id: string
title: string | null
notebookId: string | null
daysStale: number
}

View File

@@ -0,0 +1,156 @@
import type { DashboardPath } from '@/lib/dashboard/path-types'
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
export interface BriefingPathsInput {
recentNotes: Array<{
id: string
title: string | null
content: string
notebookId: string | null
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
/** Pistes instantanées à partir du briefing déjà chargé — sans requête lourde. */
export function buildFastPathsFromBriefing(input: BriefingPathsInput): DashboardPath[] {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
if (focus) {
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
for (const ins of input.insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 88,
title: other.title || 'Untitled',
description: ins.insight,
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: 70,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: 65,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 60,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 55,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths.sort((a, b) => b.priority - a.priority).slice(0, 8)
}

View File

@@ -0,0 +1,297 @@
import 'server-only'
import prisma from '@/lib/prisma'
import type { DashboardPath } from '@/lib/dashboard/path-types'
export type { DashboardPath, DashboardPathType } from '@/lib/dashboard/path-types'
interface BuildPathsInput {
userId: string
aiActive: boolean
recentNotes: Array<{
id: string
title: string | null
notebookId: string | null
updatedAt: Date
content: string
}>
inboxCount: number
dueFlashcards: number
insights: Array<{
id: string
insight: string
score: number
viewed: boolean
note1: { id: string; title: string | null }
note2: { id: string; title: string | null }
note1Excerpt?: string
note2Excerpt?: string
}>
bridgeSuggestions: Array<{
clusterAId: number
clusterBId: number
clusterAName: string
clusterBName: string
suggestedTitle: string
suggestedContent: string
justification: string
}>
agentSuggestions: Array<{
id: string
topic: string
reason: string
clusterId: number | null
}>
}
function excerpt(text: string, max = 120): string {
const plain = text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim()
if (plain.length <= max) return plain
return `${plain.slice(0, max)}`
}
function jaccardSimilarity(a: string, b: string): number {
const tokenize = (s: string) =>
new Set(s.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 3))
const A = tokenize(a)
const B = tokenize(b)
if (A.size === 0 || B.size === 0) return 0
let intersection = 0
A.forEach(w => { if (B.has(w)) intersection++ })
return intersection / (A.size + B.size - intersection)
}
async function findLinkSuggestions(
userId: string,
sourceNote: { id: string; title: string | null; content: string },
limit = 2,
): Promise<Array<{ noteId: string; noteTitle: string; snippet: string; score: number }>> {
const sourcePlain = excerpt(sourceNote.content, 400)
const sourceText = `${sourceNote.title || ''} ${sourcePlain}`.toLowerCase()
const candidates = await prisma.note.findMany({
where: { userId, id: { not: sourceNote.id }, isArchived: false, trashedAt: null },
select: { id: true, title: true, content: true },
take: 12,
orderBy: { updatedAt: 'desc' },
})
const scored: Array<{ noteId: string; noteTitle: string; snippet: string; score: number }> = []
for (const note of candidates) {
const notePlain = excerpt(note.content, 300)
const sim = jaccardSimilarity(sourceText, `${note.title || ''} ${notePlain}`)
if (sim < 0.06) continue
scored.push({
noteId: note.id,
noteTitle: note.title || '',
snippet: notePlain.slice(0, 100),
score: Math.round(sim * 100),
})
}
return scored.sort((a, b) => b.score - a.score).slice(0, limit)
}
function pathsFromInsights(
focus: BuildPathsInput['recentNotes'][0],
insights: BuildPathsInput['insights'],
paths: DashboardPath[],
): void {
for (const ins of insights
.filter(i => i.note1.id === focus.id || i.note2.id === focus.id)
.slice(0, 2)) {
const other = ins.note1.id === focus.id ? ins.note2 : ins.note1
if (paths.some(p => p.note2Id === other.id && p.type === 'connect')) continue
paths.push({
id: `connect-${focus.id}-${other.id}`,
type: 'connect',
priority: 90 - paths.length,
title: other.title || 'Untitled',
description: ins.insight || ins.note2Excerpt || ins.note1Excerpt || '',
actionKey: 'compare',
noteId: focus.id,
note2Id: other.id,
notebookId: focus.notebookId ?? undefined,
score: Math.round(ins.score * 100),
})
}
}
async function findOpenLoops(userId: string, limit = 3): Promise<Array<{ id: string; title: string | null; notebookId: string | null; daysStale: number }>> {
const threeDaysAgo = new Date()
threeDaysAgo.setDate(threeDaysAgo.getDate() - 3)
const thirtyDaysAgo = new Date()
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30)
const stale = await prisma.note.findMany({
where: {
userId,
trashedAt: null,
isArchived: false,
updatedAt: { lt: threeDaysAgo, gte: thirtyDaysAgo },
OR: [
{ notebookId: null },
{ isPinned: true },
{ reminder: { not: null }, isReminderDone: false },
],
},
select: { id: true, title: true, notebookId: true, updatedAt: true },
orderBy: { updatedAt: 'desc' },
take: limit,
})
const now = Date.now()
return stale.map(n => ({
id: n.id,
title: n.title,
notebookId: n.notebookId,
daysStale: Math.floor((now - n.updatedAt.getTime()) / (1000 * 60 * 60 * 24)),
}))
}
export async function buildDashboardPaths(input: BuildPathsInput): Promise<DashboardPath[]> {
const paths: DashboardPath[] = []
const focus = input.recentNotes[0]
let focusClusterId: number | null = null
if (focus) {
const member = await prisma.clusterMember.findFirst({
where: { userId: input.userId, noteId: focus.id },
select: { clusterId: true },
})
focusClusterId = member?.clusterId ?? null
paths.push({
id: `continue-${focus.id}`,
type: 'continue',
priority: 100,
title: focus.title || 'Untitled',
description: excerpt(focus.content, 140),
actionKey: 'continue',
noteId: focus.id,
notebookId: focus.notebookId ?? undefined,
})
pathsFromInsights(focus, input.insights, paths)
const linkSuggestions = await findLinkSuggestions(input.userId, focus, 2)
for (const link of linkSuggestions) {
paths.push({
id: `add-link-${focus.id}-${link.noteId}`,
type: 'add-link',
priority: 75,
title: link.noteTitle || 'Untitled',
description: link.snippet,
actionKey: 'addLink',
noteId: focus.id,
note2Id: link.noteId,
notebookId: focus.notebookId ?? undefined,
score: link.score,
})
}
}
const freshInsight = input.insights.find(i => !i.viewed)
if (freshInsight) {
paths.push({
id: `resurface-${freshInsight.id}`,
type: 'resurface',
priority: 85,
title: `${freshInsight.note1.title || '…'}${freshInsight.note2.title || '…'}`,
description: freshInsight.insight,
actionKey: 'openInsight',
insightId: freshInsight.id,
noteId: freshInsight.note1.id,
note2Id: freshInsight.note2.id,
score: Math.round(freshInsight.score * 100),
})
}
for (const bridge of input.bridgeSuggestions.slice(0, 2)) {
const relevant = focusClusterId === null
|| bridge.clusterAId === focusClusterId
|| bridge.clusterBId === focusClusterId
paths.push({
id: `bridge-${bridge.clusterAId}-${bridge.clusterBId}`,
type: 'bridge',
priority: relevant ? 80 : 55,
title: bridge.suggestedTitle,
description: bridge.justification || bridge.suggestedContent.slice(0, 140),
actionKey: 'createBridge',
clusterAId: bridge.clusterAId,
clusterBId: bridge.clusterBId,
notebookId: focus?.notebookId ?? undefined,
})
}
for (const agent of input.agentSuggestions.slice(0, 2)) {
const relevant = focusClusterId !== null && agent.clusterId === focusClusterId
paths.push({
id: `research-${agent.id}`,
type: 'research',
priority: relevant ? 78 : 50,
title: agent.topic,
description: agent.reason,
actionKey: 'createAgent',
agentSuggestionId: agent.id,
notebookId: focus?.notebookId ?? undefined,
})
}
if (focusClusterId !== null) {
const cluster = await prisma.noteCluster.findFirst({
where: { userId: input.userId, clusterId: focusClusterId },
select: { name: true, noteCount: true },
})
if (cluster) {
paths.push({
id: `explore-cluster-${focusClusterId}`,
type: 'explore',
priority: 60,
title: cluster.name || `Cluster ${focusClusterId}`,
description: `${cluster.noteCount} notes in this theme`,
actionKey: 'exploreTheme',
clusterAId: focusClusterId,
})
}
}
if (input.inboxCount > 0) {
paths.push({
id: 'organize-inbox',
type: 'organize',
priority: 70,
title: `${input.inboxCount} notes`,
description: 'inbox',
actionKey: 'organizeInbox',
})
}
if (input.dueFlashcards > 0) {
paths.push({
id: 'review-flashcards',
type: 'review',
priority: 65,
title: `${input.dueFlashcards}`,
description: 'flashcards',
actionKey: 'reviewCards',
})
}
paths.push({
id: 'daily-journal',
type: 'daily',
priority: 40,
title: new Date().toISOString().slice(0, 10),
description: 'daily',
actionKey: 'openDaily',
})
return paths
.sort((a, b) => b.priority - a.priority)
.slice(0, 8)
}
export async function buildOpenLoops(userId: string) {
return findOpenLoops(userId, 5)
}