## Translation Files - Add 11 new language files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ missing translation keys across all 15 languages - New sections: notebook, pagination, ai.batchOrganization, ai.autoLabels - Update nav section with workspace, quickAccess, myLibrary keys ## Component Updates - Update 15+ components to use translation keys instead of hardcoded text - Components: notebook dialogs, sidebar, header, note-input, ghost-tags, etc. - Replace 80+ hardcoded English/French strings with t() calls - Ensure consistent UI across all supported languages ## Code Quality - Remove 77+ console.log statements from codebase - Clean up API routes, components, hooks, and services - Keep only essential error handling (no debugging logs) ## UI/UX Improvements - Update Keep logo to yellow post-it style (from-yellow-400 to-amber-500) - Change selection colors to #FEF3C6 (notebooks) and #EFB162 (nav items) - Make "+" button permanently visible in notebooks section - Fix grammar and syntax errors in multiple components ## Bug Fixes - Fix JSON syntax errors in it.json, nl.json, pl.json, zh.json - Fix syntax errors in notebook-suggestion-toast.tsx - Fix syntax errors in use-auto-tagging.ts - Fix syntax errors in paragraph-refactor.service.ts - Fix duplicate "fusion" section in nl.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> Ou une version plus courte si vous préférez : feat(i18n): Add 15 languages, remove logs, update UI components - Create 11 new translation files (es, de, pt, ru, zh, ja, ko, ar, hi, nl, pl) - Add 100+ translation keys: notebook, pagination, AI features - Update 15+ components to use translations (80+ strings) - Remove 77+ console.log statements from codebase - Fix JSON syntax errors in 4 translation files - Fix component syntax errors (toast, hooks, services) - Update logo to yellow post-it style - Change selection colors (#FEF3C6, #EFB162) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
61 KiB
| stepsCompleted | inputDocuments | workflowType | project_name | user_name | date | communication_language | document_output_language | focusArea | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
architecture | Keep - Notebooks & Labels Contextuels | Ramez | 2026-01-11 | French | English | Notebooks with Contextual Labels Feature |
Architecture Decision Document - Notebooks & Labels Contextuels
This document builds collaboratively through step-by-step discovery. Sections are appended as we work through each architectural decision together.
Project Context Analysis
Requirements Overview
Functional Requirements:
Le projet Notebooks & Labels Contextuels est une refonte architecturale majeure du système d'organisation de Keep, transformant un modèle de tags plat en une structure hiérarchique contextuelle avec intelligence artificielle intégrée.
1. Structure en Notebooks
- Notebooks comme organisation principale (remplacement/évolution des tags globaux)
- Notes générales (Inbox) pour notes non organisées
- Une note appartient à UN seul notebook (ou aucune)
- Labels 100% contextuels à chaque notebook (isolement total)
2. UX Interactions Complexes
- Drag & drop à deux niveaux : réorganiser les notebooks + déplacer des notes
- Menu contextuel alternatif pour le déplacement
- Création contextuelle de notebooks et labels
- Navigation fluide entre Inbox et Notebooks
3. Intégration IA (6 Features)
- IA1: Suggestion automatique de notebook (quand note créée dans Inbox)
- IA2: Suggestion de labels contextuels (filtrés par notebook actif)
- IA3: Organisation batch intelligente (Inbox → Notebooks)
- IA4: Création automatique de labels (détection thèmes récurrents)
- IA5: Recherche sémantique contextuelle (limitée au notebook)
- IA6: Synthèse automatique par notebook
4. Migration Brownfield
- Migration douce depuis le système de tags plat existant
- Notebook par défaut pour préserver les labels existants
- ZÉRO breaking changes - le système actuel continue de fonctionner
Non-Functional Requirements:
Les NFRs critiques qui façonneront l'architecture :
Performance:
- Drag & drop en temps réel (< 100ms latence perçue)
- Recherche sémantique < 300ms pour 1000 notes (existant)
- Suggestions IA < 2s (existant)
- Chargement de sidebar instantané (< 200ms)
Sécurité & Intégrité:
- Labels isolés par notebook (pas de fuite de données entre notebooks)
- Cascade delete maîtrisé : notebook supprimé → notes → Inbox (pas de perte de données)
- Contrainte d'unicité :
@@unique([notebookId, name])pour les labels
Brownfield Compatibility:
- ZERO breaking changes - le système de tags actuel continue de fonctionner
notebookIdoptionnel sur Note (null = Inbox)notebookIdobligatoire sur Label (migration avec valeur par défaut 'TEMP_MIGRATION')- Rollback possible si nécessaire
Scalability:
- Supporte jusqu'à 100+ notebooks par utilisateur
- Supporte jusqu'à 50+ labels par notebook
- Performance dégradée gracieusement avec 1000+ notes
Scale & Complexity
Project Complexity: MEDIUM-HIGH
Complexity Indicators:
- ✅ Real-time features (drag & drop synchronisé à deux niveaux)
- ✅ IA multi-facettes (6 features distinctes réutilisant l'infrastructure existante)
- ✅ Migration de données brownfield (système de tags vers notebooks)
- ❌ PAS de multi-tenancy (single-user only - simplifie l'architecture)
- ❌ PAS de compliance réglementaire spécifique (GDPR basique uniquement)
- ✅ Intégration backend complexe (Prisma + IA)
Primary Technical Domain: Full-stack Web with AI
- Frontend: React/Next.js avec interactions riches (sidebar, drag & drop, modals)
- Backend: Server Actions + API Routes (existants à étendre)
- Database: Prisma ORM + SQLite (existants)
- AI: OpenAI/Ollama (infrastructure existante à adapter pour être contextuelle)
Estimated Architectural Components: ~8-10 composants majeurs
- Notebook Management (CRUD + ordre manuel)
- Label Management (contextuel par notebook)
- Drag & Drop System (deux niveaux)
- IA Suggestion Engine (adaptation existante)
- Migration Service (tags → notebooks)
- Search Contextualization (limitation par notebook)
- State Management (sidebar + grid + optimistic UI)
- Inbox/General Notes (zone temporaire)
Technical Constraints & Dependencies
Contraintes Existantes:
- Next.js 16.1.1 avec App Router (doit être compatible)
- Prisma 5.22.0 + SQLite (doit étendre le schema existant)
- React 19.2.3 strict mode (doit respecter les patterns existants)
- Muuri pour Masonry grid (doit cohabiter avec le nouveau drag & drop)
- Server Actions + revalidatePath (pattern existant à respecter)
Dépendances IA:
- Vercel AI SDK 6.0.23 (déjà en place)
- Auto-tagging system (existant - à adapter pour être contextuel)
- Semantic search (existant - à limiter par notebook)
- Language detection (existant - à réutiliser)
Contraintes UX:
- Responsive design (desktop priorité, mobile secondaire)
- Accessibilité WCAG AA (existant)
- Dark mode (existant)
- Performance targets (existant - à maintenir)
Cross-Cutting Concerns Identified
1. State Management & Synchronization
- Challenge: État global distribué (sidebar + grid + modals + IA)
- Impact: Nécessite une architecture d'état cohérente pour éviter les incohérences
- Patterns: React Context + useOptimistic + Server Actions (existants)
2. Drag & Drop à Deux Niveaux
- Challenge: Unifier le drag & drop Muuri (existant) avec le drag & drop notebooks (nouveau)
- Impact: UX cohérente pour l'utilisateur, code maintenable
- Patterns: @dnd-kit ou Muuri natif (à décider)
3. IA Contextuelle
- Challenge: Adapter l'auto-tagging existant pour filtrer par notebook
- Impact: Réutilisation maximale du code existant, pas de duplication
- Patterns: Services IA avec paramètre
notebookId
4. Migration de Données
- Challenge: Migrer les tags globaux vers les notebooks sans perte
- Impact: User experience douce, pas de données perdues
- Patterns: Migration script + notebook par défaut + IA-assistée
5. Performance de la Recherche
- Challenge: Recherche sémantique limitée au notebook actif
- Impact: Résultats pertinents, performance acceptable
- Patterns: Filtrage au niveau Prisma query (pas post-traitement)
6. Undo/Redo pour Actions Déstructives
- Challenge: Permettre d'annuler les déplacements de notes, suppressions de notebooks
- Impact: User confiance, UX sans peur
- Patterns: Historique des actions ou Server Actions avec rollback
Step 3: Library & Technology Evaluation (Brownfield Adaptation)
Brownfield Context
Ce projet N'est PAS greenfield - Keep existe déjà avec une stack mature et des drag & drop fonctionnels. Cette section évalue les technologies existantes et identifie ce qui doit être ajouté.
Existing Technology Stack
✅ Technologies déjà en place (à réutiliser) :
| Component | Technology | Status | Usage dans le projet Notebooks |
|---|---|---|---|
| Masonry Layout + Drag & Drop | Muuri | ✅ En production | Réorganisation des notes (niveau 1) + Réorganisation notebooks (niveau 2 - NOUVEAU) |
| AI SDK | Vercel AI SDK 6.0.23 | ✅ En production | Adaptation pour IA contextuelle par notebook |
| Database ORM | Prisma 5.22.0 | ✅ En production | Extension du schema avec Notebook + Label |
| Server Actions | Next.js Server Actions | ✅ En production | CRUD operations pour notebooks/labels |
| Auto-tagging IA | Custom implementation | ✅ En production | Adapter pour filtrer par notebookId |
| Semantic Search | Embeddings + vector search | ✅ En production | Limiter scope au notebook actif |
| State Management | React Context + useOptimistic | ✅ En production | Étendre pour gérer état notebooks |
| Framework | Next.js 16.1.1 + React 19.2.3 | ✅ En production | Base de développement |
| Database | SQLite | ✅ En production | Ajout tables Notebook, Label |
🔑 DECISION ARCHITECTURALE CRITIQUE #1: Drag & Drop Unifié
Question: Comment implémenter le drag & drop pour les deux niveaux (notes + notebooks) ?
Option Évaluée: @dnd-kit
- ❌ REJETÉ - Introduirait une deuxième bibliothèque de drag & drop
- ❌ Dupliquerait les dépendances
- ❌ UX potentiellement incohérente (deux "feels" différents)
- ✅ Modern mais déjà disponible dans Muuri
DÉCISION RETENUE: Utiliser Muuri pour les DEUX niveaux de drag & drop
| Niveau | Usage | Implémentation Muuri |
|---|---|---|
| Niveau 1 | Réorganisation des notes dans la masonry grid | ✅ DÉJÀ FONCTIONNEL (existant) |
| Niveau 2 | Réorganisation des notebooks dans la sidebar | 🆕 À IMPLÉMENTER avec Muuri |
Justification:
- Single Library Principle - Une seule bibliothèque de drag & drop à maîtriser
- Consistance UX - Même comportement visuel et interactif partout dans l'app
- Moins de dépendances - Pas de @dnd-kit, @hello-pangea/dnd, ou react-beautiful-dnd
- Performance - Muuri est déjà optimisé pour le projet (déjà testé en production)
- Code maintenance - Un seul pattern de drag & drop à maintenir
Implémentation Technique:
// Niveau 1: Existant (masonry-grid.tsx)
// Déjà implémenté avec Muuri - PAS DE CHANGEMENT
// Niveau 2: Nouveau (notebooks-sidebar.tsx)
// À implémenter avec la même configuration Muuri
const notebooksMuuri = new Muuri(notebooksGridRef.current, {
dragEnabled: true,
dragContainer: document.body,
dragStartPredicate: { distance: 5, delay: 0 },
// Même configuration que masonry-grid pour consistance
})
Nouvelles Dépendances Requises
✅ AUCUNE nouvelle dépendance majeure requise
Ce projet va être implémenté avec 100% des technologies existantes. Les seuls ajouts potentiels sont :
| Type | Dépendance | Raison | Taille |
|---|---|---|---|
| Icons | lucide-react (optionnel) |
Icons pour notebooks | ~KB (déjà utilisé dans le projet) |
| Utilities | clsx / cn (optionnel) |
Classname utilities | ~KB (peut être déjà là) |
Decision: Utiliser les utilitaires déjà existants dans le projet. PAS de nouvelles dépendances npm majeures.
Step 4: Architectural Decisions
Cette section documente TOUTES les décisions architecturales critiques pour chaque composant majeur du projet.
🔑 DECISION ARCHITECTURALE #2: Database Schema & Data Model
Question: Comment modéliser les notebooks et labels contextuels dans Prisma ?
Contraintes:
- ZERO breaking changes sur le système existant
- Labels doivent être isolés par notebook (pas de fuite)
- Support pour "Notes générales" (Inbox) sans notebook
- Cascade delete contrôlé (pas de perte de données)
DÉCISION RETENUE: Schéma Prisma Extensible avec Relations Optionnelles
// NOUVEAU: Modèle Notebook
model Notebook {
id String @id @default(cuid())
name String
icon String? // Emoji ou icon name
color String? // Hex color pour personnalisation
order Int // Ordre manuel (drag & drop)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
notes Note[] // Une note peut appartenir à un notebook
labels Label[] // Labels sont contextuels à ce notebook
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, order])
@@index([userId])
}
// NOUVEAU: Modèle Label (contextuel)
model Label {
id String @id @default(cuid())
name String
color String?
notebookId String // OBLIGATOIRE: Label appartient à un notebook
notebook Notebook @relation(fields: [notebookId], references: [id], onDelete: Cascade)
notes Note[] // Plusieurs notes peuvent avoir ce label
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([notebookId, name]) // Nom unique DANS le notebook
@@index([notebookId])
}
// MODIFIÉ: Modèle Note existant
model Note {
id String @id @default(cuid())
title String?
content String
isPinned Boolean @default(false)
size String @default("small")
order Int
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// NOUVEAU: Relation optionnelle avec Notebook
notebookId String? // NULL = "Notes générales" (Inbox)
notebook Notebook? @relation(fields: [notebookId], references: [id], onDelete: SetNull)
// NOUVEAU: Labels contextuels (relation many-to-many)
labels Label[]
// ... autres champs existants (embeddings, etc.)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, order])
@@index([userId, notebookId]) // Pour filtrer par notebook efficacement
}
Justification:
notebookIdoptionnel sur Note - Permet "Notes générales" (null = Inbox)onDelete: SetNull- Notebook supprimé → notes deviennent "générales" (pas de perte)@@unique([notebookId, name])- Labels uniques par notebook (pas globaux)- Relation many-to-many Note <-> Label - Prisma gère la table de jointure automatiquement
- Indexes sur
[userId, notebookId]- Recherche performante par notebook
Migration Strategy:
// Migration script (exécuté une fois)
export async function migrateToNotebooks() {
// 1. Créer notebook "TEMP_MIGRATION" pour tous les labels existants
const tempNotebook = await prisma.notebook.create({
data: {
name: "Labels Migrés",
userId: currentUserId,
order: 999,
}
})
// 2. Attribuer tous les labels existants à ce notebook
await prisma.label.updateMany({
data: { notebookId: tempNotebook.id }
})
// 3. Laisser les notes existantes sans notebook (Inbox)
// Elles pourront être réorganisées plus tard par l'utilisateur
}
🔑 DECISION ARCHITECTURALE #3: State Management Architecture
Question: Comment gérer l'état distribué (sidebar + grid + modals + IA) ?
Contraintes:
- État global distribué (plusieurs zones de l'UI)
- Optimistic UI pour drag & drop (latence perçue < 100ms)
- IA suggestions asynchrones
- Server Actions pour persistance
DÉCISION RETENUE: React Context + useOptimistic + Server Actions (Hybrid)
Architecture en Couches:
┌─────────────────────────────────────────────────────┐
│ LAYER 1: UI State (Local Component State) │
│ - Modal open/close │
│ - Input focus │
│ - Temporary UI states │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ LAYER 2: Optimistic State (useOptimistic) │
│ - Drag & drop order changes │
│ - Note moves between notebooks │
│ - Notebook reordering │
│ - CRUD operations (création/suppression notebooks) │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ LAYER 3: Global State (React Context) │
│ - Current notebook filter │
│ - List of notebooks (sidebar) │
│ - Labels contextuels (par notebook) │
└─────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────┐
│ LAYER 4: Server State (Server Actions + Cache) │
│ - Persistent data (Prisma) │
│ - Next.js cache (revalidatePath) │
│ - IA computations (asynchronous) │
└─────────────────────────────────────────────────────┘
Implémentation:
// app/context/notebooks-context.tsx
export type NotebooksContextValue = {
// État global
notebooks: Notebook[]
currentNotebook: Notebook | null // null = "Notes générales"
currentLabels: Label[] // Labels du notebook actuel
// Actions optimistes
createNotebookOptimistic: (data: CreateNotebookInput) => Promise<void>
updateNotebookOrderOptimistic: (notebookIds: string[]) => Promise<void>
moveNoteToNotebookOptimistic: (noteId: string, notebookId: string | null) => Promise<void>
// Actions IA (non-optimistes, asynchrones)
suggestNotebookForNote: (noteContent: string) => Promise<Notebook | null>
suggestLabelsForNote: (noteContent: string, notebookId: string) => Promise<Label[]>
}
export const NotebooksContext = createContext<NotebooksContextValue | null>(null)
export function useNotebooks() {
const context = useContext(NotebooksContext)
if (!context) throw new Error('useNotebooks must be used within NotebooksProvider')
return context
}
// app/providers.tsx
export function NotebooksProvider({ children }: { children: ReactNode }) {
const [notebooks, setNotebooks] = useState<Notebook[]>([])
const [currentNotebook, setCurrentNotebook] = useState<Notebook | null>(null)
// Charger les notebooks au mount
useEffect(() => {
loadNotebooks().then(setNotebooks)
}, [])
// Charger les labels du notebook actuel
const currentLabels = useMemo(() => {
if (!currentNotebook) return []
return notebooks.find(nb => nb.id === currentNotebook.id)?.labels ?? []
}, [currentNotebook, notebooks])
// Optimistic actions avec useOptimistic
const [optimisticNotebooks, addOptimisticNotebook] = useOptimistic(
notebooks,
(state, newNotebook: Notebook) => [...state, newNotebook]
)
const createNotebookOptimistic = async (data: CreateNotebookInput) => {
// 1. Optimistic update
const tempId = `temp-${Date.now()}`
addOptimisticNotebook({ ...data, id: tempId })
// 2. Server Action
try {
const result = await createNotebook(data)
// 3. Revalidation triggers reload with real data
revalidatePath('/')
} catch (error) {
// 4. Error handling - rollback automatic
}
}
return (
<NotebooksContext.Provider value={{
notebooks: optimisticNotebooks,
currentNotebook,
currentLabels,
createNotebookOptimistic,
// ... autres actions
}}>
{children}
</NotebooksContext.Provider>
)
}
Justification:
- useOptimistic - React 19 feature native, parfaite pour drag & drop
- React Context - Suffisant pour l'état global (pas besoin de Redux/Zustand)
- Server Actions - Pattern existant à maintenir pour la persistance
- Séparation des préoccupations - Chaque couche a sa responsabilité
🔑 DECISION ARCHITECTURALE #4: IA Contextuelle Architecture
Question: Comment adapter l'IA existante pour être contextuelle par notebook ?
Contraintes:
- Réutiliser l'infrastructure existante (Vercel AI SDK)
- Filtrer les suggestions par notebook actif
- Pas de duplication de code
- 6 features IA à implémenter
DÉCISION RETENUE: Pattern Adapter avec Filtrage au Niveau Service
Architecture IA Contextuelle:
┌──────────────────────────────────────────────────────┐
│ IA Services Existants (à réutiliser) │
│ - autoTagService.ts (suggestions de tags) │
│ - semanticSearchService.ts (recherche sémantique) │
│ - languageDetectionService.ts (détection langue) │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ NOUVEAU: Contextual Adapter Layer │
│ - contextualAutoTagService(notebookId) │
│ - contextualSearchService(notebookId) │
│ - notebookSuggestionService() │
└──────────────────────────────────────────────────────┘
↓
┌──────────────────────────────────────────────────────┐
│ Server Actions (UI Layer) │
│ - suggestLabels(noteId, notebookId) │
│ - suggestNotebook(noteId) │
│ - organizeInbox(noteIds) │
└──────────────────────────────────────────────────────┘
Implémentation:
// lib/ai/services/contextual-auto-tag.service.ts
import { autoTagService } from './auto-tag.service'
export class ContextualAutoTagService {
constructor(private notebookId: string) {}
/**
* Suggère des labels pour une note, filtrés par notebook
*/
async suggestLabels(noteContent: string): Promise<string[]> {
// 1. Récupérer tous les labels du notebook courant
const existingLabels = await prisma.label.findMany({
where: { notebookId: this.notebookId }
})
// 2. Utiliser le service existant pour générer des suggestions
const allSuggestions = await autoTagService.suggestTags(noteContent)
// 3. Filtrer: retourner seulement les labels qui existent dans ce notebook
const contextualSuggestions = allSuggestions.filter(suggestion =>
existingLabels.some(label => label.name === suggestion)
)
// 4. Si pas de correspondance, suggérer de créer un nouveau label (optionnel)
if (contextualSuggestions.length === 0 && existingLabels.length < 50) {
// Suggérer le meilleur candidat pour création
return [allSuggestions[0]] // Premier = plus pertinent
}
return contextualSuggestions
}
/**
* Crée automatiquement un label si le thème est récurrent (IA4)
*/
async createLabelIfRecurring(noteContent: string): Promise<Label | null> {
const suggestions = await autoTagService.suggestTags(noteContent)
const topSuggestion = suggestions[0]
// Vérifier si ce label existe déjà
const existing = await prisma.label.findFirst({
where: {
notebookId: this.notebookId,
name: topSuggestion
}
})
if (existing) return existing // Déjà existe
// Créer le nouveau label
return await prisma.label.create({
data: {
name: topSuggestion,
notebookId: this.notebookId,
color: getRandomColor()
}
})
}
}
// app/actions/ai.ts (Server Actions)
export async function suggestContextualLabels(noteId: string, notebookId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id }
})
if (!note) throw new Error('Note not found')
const service = new ContextualAutoTagService(notebookId)
const suggestions = await service.suggestLabels(note.content)
return suggestions
}
export async function suggestNotebookForNote(noteId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id },
include: {
notebook: true
}
})
if (!note || note.notebook) {
// Déjà dans un notebook, pas de suggestion
return null
}
// Récupérer tous les notebooks de l'utilisateur
const notebooks = await prisma.notebook.findMany({
where: { userId: session.user.id },
include: { labels: true }
})
// Utiliser l'IA pour suggérer le meilleur notebook
const prompt = `
Given this note content and the list of available notebooks,
suggest the most appropriate notebook.
Note: ${note.content}
Available notebooks:
${notebooks.map(nb => `- ${nb.name}: ${nb.labels.map(l => l.name).join(', ')}`).join('\n')}
Return ONLY the notebook name, or "NONE" if no good match.
`
const response = await generateText({
model: getOpenAIModel(),
prompt
})
const suggestedName = response.text.trim()
const suggestedNotebook = notebooks.find(nb => nb.name === suggestedName)
return suggestedNotebook
}
Mapping des 6 Features IA:
| Feature IA | Service Existant | Adaptation Contextuelle |
|---|---|---|
| IA1: Suggestion Notebook | N/A (nouveau) | suggestNotebookForNote() - analyse contenu + notebooks existants |
| IA2: Suggestion Labels | autoTagService |
ContextualAutoTagService - filtre par notebookId |
| IA3: Organisation Batch | autoTagService |
organizeInboxBatch() - déplace notes + suggère notebooks |
| IA4: Création Auto Labels | autoTagService |
createLabelIfRecurring() - détect thèmes récurrents |
| IA5: Recherche Sémantique | semanticSearchService |
Filtrer query Prisma par notebookId |
| IA6: Synthèse Notebook | N/A (nouveau) | generateNotebookSummary() - résume toutes les notes d'un notebook |
Justification:
- Réutilisation maximum - Services existants non modifiés
- Pattern Adapter - Couche d'adaptation légère pour le contexte
- Filtrage DB-level - Performance optimale (pas de post-traitement)
- Testabilité - Services isolés, faciles à tester unitairement
🔑 DECISION ARCHITECTURALE #5: Drag & Drop Implementation Details
Question: Comment implémenter concrètement le drag & drop de notebooks dans la sidebar avec Muuri ?
Contraintes:
- Utiliser Muuri (pas de @dnd-kit)
- Même configuration que masonry-grid pour consistance
- Performance < 100ms latence perçue
- Optimistic UI avec synchronisation serveur
DÉCISION RETENUE: Instance Muuri dédiée pour la sidebar
Architecture:
// components/notebooks-sidebar.tsx
'use client'
import { useEffect, useRef, useCallback } from 'react'
import { useNotebooks } from '@/app/context/notebooks-context'
export function NotebooksSidebar() {
const { notebooks, currentNotebook, setCurrentNotebook, updateNotebookOrderOptimistic } = useNotebooks()
const notebooksGridRef = useRef<HTMLDivElement>(null)
const notebooksMuuri = useRef<any>(null)
const handleDragEnd = useCallback(async (grid: any) => {
if (!grid) return
const items = grid.getItems()
const notebookIds = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
.filter((id: any): id is string => !!id)
// Optimistic update via useOptimistic
await updateNotebookOrderOptimistic(notebookIds)
}, [updateNotebookOrderOptimistic])
useEffect(() => {
let isMounted = true
const initNotebooksMuuri = async () => {
// Import Muuri dynamiquement
const MuuriClass = (await import('muuri')).default
if (!isMounted || !notebooksGridRef.current) return
// Configuration IDENTIQUE à masonry-grid pour consistance
const layoutOptions = {
dragEnabled: true,
dragContainer: document.body,
dragStartPredicate: {
distance: 5, // Plus sensible que la masonry (sidebar plus compacte)
delay: 0,
},
dragPlaceholder: {
enabled: true,
createElement: (item: any) => {
const el = item.getElement().cloneNode(true)
el.style.opacity = '0.5'
return el
},
},
// Layout vertical pour sidebar
layout: {
fillGaps: false,
horizontal: false,
alignRight: false,
alignBottom: false,
rounding: false,
}
}
notebooksMuuri.current = new MuuriClass(notebooksGridRef.current, layoutOptions)
.on('dragEnd', () => handleDragEnd(notebooksMuuri.current))
}
initNotebooksMuuri()
return () => {
isMounted = false
notebooksMuuri.current?.destroy()
}
}, [handleDragEnd])
// Synchroniser quand les notebooks changent
useEffect(() => {
if (notebooksMuuri.current) {
notebooksMuuri.current.refreshItems().layout()
}
}, [notebooks])
return (
<div className="notebooks-sidebar w-64 bg-gray-50 p-4">
<h2 className="text-sm font-semibold mb-4">Notebooks</h2>
<div ref={notebooksGridRef} className="notebooks-list">
{/* "Notes générales" - toujours en premier */}
<div
className={`notebook-item p-3 rounded cursor-pointer hover:bg-gray-200 ${
!currentNotebook ? 'bg-blue-100' : ''
}`}
onClick={() => setCurrentNotebook(null)}
data-id="general-notes"
>
📝 Notes générales
</div>
{/* Liste des notebooks Muuri */}
{notebooks.map(notebook => (
<div
key={notebook.id}
className={`notebook-item p-3 rounded cursor-pointer hover:bg-gray-200 ${
currentNotebook?.id === notebook.id ? 'bg-blue-100' : ''
}`}
onClick={() => setCurrentNotebook(notebook)}
data-id={notebook.id}
>
{notebook.icon || '📚'} {notebook.name}
<span className="ml-auto text-xs text-gray-500">
{notebook.notes?.length || 0} notes
</span>
</div>
))}
</div>
</div>
)
}
Drag & Drop Notes vers Notebooks:
// Extension de masonry-grid.tsx
// Ajouter des zones de drop pour chaque notebook
const handleNoteDragEnd = useCallback(async (grid: any) => {
if (!grid) return
const items = grid.getItems()
const noteIds = items
.map((item: any) => item.getElement()?.getAttribute('data-id'))
.filter((id: any): id is string => !!id)
// Update order
await updateFullOrderWithoutRevalidation(noteIds)
// CHANGEMENT: Détecter si note déposée sur un notebook
const draggedOverNotebook = detectNotebookDropTarget()
if (draggedOverNotebook) {
await moveNotesToNotebook(noteIds, draggedOverNotebook.id)
}
}, [])
function detectNotebookDropTarget(): Notebook | null {
// Détecter quel élément de sidebar est sous la souris
const sidebarElement = document.elementFromPoint(mouseX, mouseY)
const notebookItem = sidebarElement?.closest('.notebook-item')
if (!notebookItem) return null
const notebookId = notebookItem.getAttribute('data-id')
if (notebookId === 'general-notes') return null // Inbox
return notebooks.find(nb => nb.id === notebookId) || null
}
Justification:
- Configuration partagée - Options drag & drop synchronisées avec masonry-grid
- Performance - Muuri optimisé pour sidebar (layout vertical simple)
- UX cohérente - Même comportement de drag dans toute l'app
- Optimistic UI - useOptimistic pour réactivité immédiate
🔑 DECISION ARCHITECTURALE #6: Migration Strategy (Brownfield)
Question: Comment migrer du système de tags plat vers notebooks sans breaking changes ?
Contraintes:
- ZERO breaking changes - l'ancien système doit continuer de fonctionner
- Données existantes préservées
- Migration transparente pour l'utilisateur
- Rollback possible si nécessaire
DÉCISION RETENUE: Migration Graduelle avec Compatibilité Ascendante
Plan de Migration en 4 Phases:
// scripts/migrate-to-notebooks.ts
/**
* PHASE 1: Préparation du Schema (Non-Breaking)
* - Ajouter les tables Notebook et Label
* - Ajouter notebookId optionnel sur Note (default: null)
* - Laisser l'ancien système de tags intact
*/
async function phase1_prepareSchema() {
// Exécuter Prisma migrate
await prisma.$executeRaw`
CREATE TABLE "Notebook" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
icon TEXT,
color TEXT,
"order" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("userId") REFERENCES "User"(id) ON DELETE CASCADE
);
CREATE TABLE "Label" (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
"notebookId" TEXT NOT NULL,
"createdAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE CASCADE,
UNIQUE("notebookId", "name")
);
ALTER TABLE "Note" ADD COLUMN "notebookId" TEXT;
ALTER TABLE "Note" ADD FOREIGN KEY ("notebookId") REFERENCES "Notebook"(id) ON DELETE SETNULL;
CREATE INDEX "Note_notebookId_idx" ON "Note"("userId", "notebookId");
`
console.log('✅ Phase 1: Schema préparé (compatible avec ancien système)')
}
/**
* PHASE 2: Migration des Données (Transparent)
* - Créer un notebook par défaut
* - Déplacer tous les labels existants vers ce notebook
* - Notes restent sans notebook (Inbox)
*/
async function phase2_migrateData(userId: string) {
// 2.1 Créer notebook de migration
const migrationNotebook = await prisma.notebook.create({
data: {
name: "Labels Migrés",
icon: "📦",
order: 999,
userId
}
})
// 2.2 Attribuer tous les labels existants à ce notebook
const existingLabels = await prisma.label.findMany({
where: { userId } // Assuming labels have userId
})
await Promise.all(
existingLabels.map(label =>
prisma.label.update({
where: { id: label.id },
data: { notebookId: migrationNotebook.id }
})
)
)
// 2.3 Laisser toutes les notes sans notebook (Inbox par défaut)
// Les notes resteront dans "Notes générales"
console.log('✅ Phase 2: Données migrées (labels préservés)')
}
/**
* PHASE 3: Activation de la Nouvelle UI (Opt-in)
* - Ajouter la sidebar notebooks
* - Permettre aux utilisateurs de créer de nouveaux notebooks
* - L'ancien système de tags continue de fonctionner via le notebook de migration
*/
async function phase3_enableNewUI() {
// Déployer la nouvelle UI avec sidebar
// Les utilisateurs peuvent commencer à utiliser les notebooks
// L'ancien système reste accessible via "Labels Migrés"
console.log('✅ Phase 3: Nouvelle UI déployée (opt-in)')
}
/**
* PHASE 4: Nettoyage (Futur - Optionnel)
* - Après adoption massive, supprimer l'ancien système
* - SEULEMENT après confirmation que tous les utilisateurs ont migré
*/
async function phase4_cleanup() {
// À exécuter manuellement après 6-12 mois
// Supprimer l'ancien système de tags global
console.log('⚠️ Phase 4: Nettoyage (futur, optionnel)')
}
Compatibility Layer (Pendant la transition):
// lib/compatibility/legacy-tags.ts
/**
* Permet à l'ancien code de continuer à fonctionner
* pendant la période de transition
*/
export class LegacyTagsCompatibility {
/**
* Retourne tous les labels (mode compatibilité)
* - Si notebookId spécifié: labels de ce notebook
* - Sinon: tous les labels (ancien comportement)
*/
static async getLabels(userId: string, notebookId?: string) {
if (notebookId) {
// Nouveau système: labels contextuels
return await prisma.label.findMany({
where: { notebook: { userId, id: notebookId } }
})
} else {
// Ancien système: tous les labels (backward compat)
return await prisma.label.findMany({
where: { notebook: { userId } }
})
}
}
/**
* Ajoute un label à une note (mode compatibilité)
* - Crée automatiquement le notebook de migration si nécessaire
*/
static async addLabelToNote(noteId: string, labelName: string) {
const note = await prisma.note.findUnique({ where: { id: noteId } })
if (!note?.notebookId) {
// Note sans notebook: utiliser le notebook de migration
const migrationNotebook = await this.getOrCreateMigrationNotebook(note.userId)
return await this.attachLabel(note, labelName, migrationNotebook.id)
} else {
// Note avec notebook: attacher au notebook existant
return await this.attachLabel(note, labelName, note.notebookId)
}
}
private static async getOrCreateMigrationNotebook(userId: string) {
let notebook = await prisma.notebook.findFirst({
where: { name: "Labels Migrés", userId }
})
if (!notebook) {
notebook = await prisma.notebook.create({
data: { name: "Labels Migrés", userId, order: 999 }
})
}
return notebook
}
private static async attachLabel(note: Note, labelName: string, notebookId: string) {
// Créer le label s'il n'existe pas
let label = await prisma.label.findFirst({
where: { notebookId, name: labelName }
})
if (!label) {
label = await prisma.label.create({
data: { name: labelName, notebookId }
})
}
// Attacher à la note
return await prisma.note.update({
where: { id: note.id },
data: { labels: { connect: { id: label.id } } }
})
}
}
Justification:
- Non-breaking - L'ancien système continue de fonctionner
- Transparent - L'utilisateur ne perd aucune donnée
- Progressif - Adoption en douceur, pas de "big bang"
- Rollback-friendly - Chaque phase peut être annulée
- Future-proof - Prépare le terrain pour supprimer l'ancien système plus tard
🔑 DECISION ARCHITECTURALE #7: Search Contextualization
Question: Comment limiter la recherche sémantique au notebook actuel ?
Contraintes:
- Réutiliser le service de recherche existant
- Filtrer au niveau DB (pas post-traitement)
- Performance < 300ms
- Supporter "Notes générales" (toutes les notes sans notebook)
DÉCISION RETENUE: Filtrage Prisma avec requête conditionnelle
Architecture:
// app/actions/semantic-search.ts
export async function contextualSemanticSearch(query: string, notebookId: string | null) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
// Générer l'embedding pour la requête
const queryEmbedding = await generateEmbedding(query)
// CONSTRUCTION CONDITIONNELLE DE LA REQUÊTE
const baseWhere = {
userId: session.user.id,
}
// FILTRAGE PAR NOTEBOOK
const whereClause = notebookId
? { ...baseWhere, notebookId } // Notebook spécifique
: { ...baseWhere, notebookId: null } // "Notes générales" uniquement
// Si on veut rechercher dans TOUT (Inbox + tous les notebooks)
// const whereClause = baseWhere // Pas de filtre notebook
// REQUÊTE PRISMA AVEC FILTRAGE
const results = await prisma.$queryRaw`
SELECT
n.*,
v.embedding <#> ${queryEmbedding} AS distance
FROM "Note" n
LEFT JOIN "_NoteToLabel" ntl ON n.id = ntl."A"
LEFT JOIN "Label" l ON ntl."B" = l.id
WHERE n."userId" = ${session.user.id}
AND ${notebookId ? Prisma.sql`n."notebookId" = ${notebookId}` : Prisma.sql`n."notebookId" IS NULL`}
ORDER BY distance ASC
LIMIT 20
`
// Retourner les résultats
return results as Note[]
}
UI Integration:
// components/search-bar.tsx
'use client'
import { useState } from 'react'
import { useNotebooks } from '@/app/context/notebooks-context'
export function SearchBar() {
const { currentNotebook } = useNotebooks()
const [query, setQuery] = useState('')
const [results, setResults] = useState<Note[]>([])
const handleSearch = async (searchQuery: string) => {
setQuery(searchQuery)
if (searchQuery.length < 2) {
setResults([])
return
}
// RECHERCHE CONTEXTUELLE
const searchResults = await contextualSemanticSearch(
searchQuery,
currentNotebook?.id || null // null = Notes générales
)
setResults(searchResults)
}
return (
<div className="search-bar">
<input
type="text"
placeholder={currentNotebook
? `Rechercher dans "${currentNotebook.name}"`
: "Rechercher dans les Notes générales"
}
value={query}
onChange={(e) => handleSearch(e.target.value)}
/>
{results.length > 0 && (
<div className="search-results">
<p className="text-xs text-gray-500">
{results.length} résultat{results.length > 1 ? 's' : ''} dans{' '}
{currentNotebook ? currentNotebook.name : 'Notes générales'}
</p>
{results.map(note => (
<SearchResultItem key={note.id} note={note} />
))}
</div>
)}
</div>
)
}
Variante: Recherche Globale (Option Future):
// Si on veut permettre la recherche dans TOUS les notebooks
export async function globalSemanticSearch(query: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const queryEmbedding = await generateEmbedding(query)
// PAS DE FILTRE NOTEBOOK
const results = await prisma.$queryRaw`
SELECT
n.*,
nb.name AS "notebookName",
v.embedding <#> ${queryEmbedding} AS distance
FROM "Note" n
LEFT JOIN "Notebook" nb ON n."notebookId" = nb.id
WHERE n."userId" = ${session.user.id}
ORDER BY distance ASC
LIMIT 20
`
return results as (Note & { notebookName: string | null })[]
}
Justification:
- Performance - Filtrage au niveau DB (pas de post-traitement JavaScript)
- Simplicité - Réutilise le service existant avec une clause
wheresupplémentaire - Flexibilité - Facile à étendre pour "recherche globale" plus tard
- UX claire - L'utilisateur sait où il recherche (notebook actuel)
🔑 DECISION ARCHITECTURALE #8: Undo/Redo System
Question: Comment permettre l'annulation d'actions destructives (déplacements, suppressions) ?
Contraintes:
- UX sans peur (l'utilisateur ose explorer)
- Actions concernées: déplacement notes, suppression notebooks, suppression labels
- Pas d'historique infini (limite à 20 actions)
- Implementation simple
DÉCISION RETENUE: Historique d'Actions Simples avec Server Actions Rollback
Architecture:
// lib/undo-history.ts
interface Action {
type: 'MOVE_NOTE' | 'DELETE_NOTEBOOK' | 'DELETE_LABEL' | 'REORDER_NOTEBOOKS'
timestamp: Date
undo: () => Promise<void>
description: string
}
class UndoHistory {
private history: Action[] = []
private maxHistory = 20
add(action: Action) {
this.history.push(action)
if (this.history.length > this.maxHistory) {
this.history.shift() // Supprimer l'action la plus ancienne
}
}
async undo() {
const lastAction = this.history.pop()
if (!lastAction) return
await lastAction.undo()
}
getLastAction(): Action | null {
return this.history[this.history.length - 1] || null
}
}
export const undoHistory = new UndoHistory()
// app/actions/notes.ts
export async function moveNoteToNotebook(noteId: string, notebookId: string | null) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const note = await prisma.note.findUnique({
where: { id: noteId, userId: session.user.id }
})
if (!note) throw new Error('Note not found')
const previousNotebookId = note.notebookId
// Exécuter l'action
const updatedNote = await prisma.note.update({
where: { id: noteId },
data: { notebookId }
})
// Enregistrer dans l'historique
undoHistory.add({
type: 'MOVE_NOTE',
timestamp: new Date(),
description: `Déplacer "${note.title || 'Sans titre'}" vers ${notebookId ? 'notebook' : 'Notes générales'}`,
undo: async () => {
// Rollback: remettre dans l'ancien notebook
await prisma.note.update({
where: { id: noteId },
data: { notebookId: previousNotebookId }
})
revalidatePath('/')
}
})
revalidatePath('/')
return updatedNote
}
export async function deleteNotebook(notebookId: string) {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
const notebook = await prisma.notebook.findUnique({
where: { id: notebookId, userId: session.user.id },
include: { notes: true }
})
if (!notebook) throw new Error('Notebook not found')
// Sauvegarder les données pour rollback
const notebookData = JSON.stringify(notebook)
// Supprimer le notebook (CASCADE delete les labels)
await prisma.notebook.delete({
where: { id: notebookId }
})
// Les notes deviennent "générales" (onDelete: SetNull)
// Enregistrer dans l'historique
undoHistory.add({
type: 'DELETE_NOTEBOOK',
timestamp: new Date(),
description: `Supprimer le notebook "${notebook.name}"`,
undo: async () => {
// Rollback: recréer le notebook
const data = JSON.parse(notebookData)
await prisma.notebook.create({
data: {
id: data.id,
name: data.name,
icon: data.icon,
color: data.color,
order: data.order,
userId: data.userId
}
})
// Recréer les labels
for (const label of data.labels) {
await prisma.label.create({
data: {
id: label.id,
name: label.name,
color: label.color,
notebookId: data.id
}
})
}
revalidatePath('/')
}
})
revalidatePath('/')
}
// app/actions/undo.ts
export async function undoLastAction() {
const session = await auth()
if (!session?.user?.id) throw new Error('Unauthorized')
await undoHistory.undo()
revalidatePath('/')
}
UI Integration:
// components/undo-toast.tsx
'use client'
import { useEffect, useState } from 'react'
import { undoLastAction } from '@/app/actions/undo'
export function UndoToast() {
const [lastAction, setLastAction] = useState<string | null>(null)
useEffect(() => {
// Poll pour détecter nouvelles actions
const interval = setInterval(() => {
// Récupérer la dernière action depuis le serveur
// (simplifié - en pratique on utiliserait un hook ou un contexte)
}, 1000)
return () => clearInterval(interval)
}, [])
if (!lastAction) return null
return (
<div className="fixed bottom-4 right-4 bg-gray-800 text-white px-4 py-2 rounded shadow-lg">
<span className="mr-2">{lastAction}</span>
<button
onClick={() => undoLastAction()}
className="underline hover:no-underline"
>
Annuler
</button>
</div>
)
}
Justification:
- Simplicité - Pas de bibliothèque complexe (pas d'immer, redux-undo, etc.)
- Performance - Historique limité à 20 actions
- UX claire - Toast avec bouton "Annuler" (comme Gmail)
- Suffisant - Couvre les cas d'usage principaux (move, delete)
Architecture Summary
Decision Matrix
Voici un résumé de toutes les décisions architecturales prises :
| # | Decision | Technology / Pattern | Key Benefit | Risk Mitigation |
|---|---|---|---|---|
| 1 | Drag & Drop Unifié | Muuri (2 instances) | UX cohérente, une seule library à maîtriser | Performance validée en production |
| 2 | Database Schema | Prisma extensible avec relations optionnelles | ZERO breaking changes, migration douce | Cascade delete maîtrisé (SetNull) |
| 3 | State Management | React Context + useOptimistic + Server Actions | État distribué gérable, optimistic UI natif React 19 | Couches séparées pour clarté |
| 4 | IA Contextuelle | Pattern Adapter avec services existants | Réutilisation maximum, pas de duplication | Filtrage DB-level pour performance |
| 5 | Drag & Drop Details | Instance Muuri dédiée pour sidebar | Configuration partagée avec masonry-grid | Sync via useOptimistic |
| 6 | Migration Strategy | 4 phases avec compatibilité ascendante | Non-breaking, rollback possible | Progressif, pas de "big bang" |
| 7 | Search Contextualization | Filtrage Prisma conditionnel | Performance DB-level, pas post-traitement | Extension facile pour recherche globale |
| 8 | Undo/Redo System | Historique simple avec rollback | UX sans peur, implementation légère | Limité à 20 actions pour performance |
Technology Stack Final
Frontend:
Framework: Next.js 16.1.1 (App Router)
UI: React 19.2.3 (Strict Mode)
State: React Context + useOptimistic (React 19 natif)
Drag & Drop: Muuri (déjà en place)
Icons: lucide-react (optionnel, déjà présent)
Backend:
API: Server Actions (pattern existant)
ORM: Prisma 5.22.0
Database: SQLite
AI:
SDK: Vercel AI SDK 6.0.23 (déjà en place)
Providers: OpenAI / Ollama (déjà configurés)
Services: Adapter layer pour contextualisation
NOUVELLES DÉPENDANCES: AUCUNE (0)
Component Architecture Map
keep-notes/
├── app/
│ ├── context/
│ │ └── notebooks-context.tsx # 🆕 État global notebooks
│ ├── actions/
│ │ ├── notebooks.ts # 🆕 CRUD notebooks
│ │ ├── labels.ts # 🆕 CRUD labels contextuels
│ │ ├── ai.ts # 🔄 Adapter IA contextuelle
│ │ ├── undo.ts # 🆕 Système d'annulation
│ │ └── notes.ts # 🔄 Extension pour notebooks
│ ├── components/
│ │ ├── notebooks-sidebar.tsx # 🆕 Sidebar avec drag & drop
│ │ ├── notebook-item.tsx # 🆕 Item notebook individuel
│ │ ├── label-chip.tsx # 🆕 Chip label contextuel
│ │ ├── masonry-grid.tsx # 🔄 Extension pour drop notebooks
│ │ └── undo-toast.tsx # 🆕 Toast d'annulation
│ └── providers.tsx # 🔄 Ajout NotebooksProvider
│
├── lib/
│ ├── ai/
│ │ └── services/
│ │ ├── contextual-auto-tag.service.ts # 🆕 IA contextuelle
│ │ ├── notebook-suggestion.service.ts # 🆕 IA1
│ │ └── notebook-summary.service.ts # 🆕 IA6
│ ├── compatibility/
│ │ └── legacy-tags.ts # 🆕 Couche compatibilité
│ └── undo-history.ts # 🆕 Historique d'actions
│
├── prisma/
│ └── schema.prisma # 🔄 Ajout Notebook + Label
│
└── scripts/
└── migrate-to-notebooks.ts # 🆕 Migration 4 phases
Data Flow Diagram
┌────────────────────────────────────────────────────────────────┐
│ USER INTERACTION │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Drag & Drop │ │ Create │ │ Search │ │
│ │ Notes │ │ Notebook │ │ Contextual │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
└─────────┼──────────────────┼──────────────────┼────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ LAYER 1: OPTIMISTIC UI (useOptimistic) │
│ - Mise à jour immédiate de l'UI │
│ - Feedback visuel instantané │
└────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ LAYER 2: SERVER ACTIONS (app/actions/) │
│ - Validation auth/permissions │
│ - Business logic │
│ - Undo history registration │
└────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ LAYER 3: DATABASE (Prisma + SQLite) │
│ - Notebook/Label CRUD │
│ - Relations & Cascade delete │
│ - Indexes pour performance │
└────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ LAYER 4: AI SERVICES (Contextual) │
│ - Notebook suggestions (IA1) │
│ - Label suggestions (IA2) │
│ - Semantic search limitée (IA5) │
└────────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────────────────────────────────────────────────────────┐
│ LAYER 5: CACHE INVALIDATION │
│ - revalidatePath('/') │
│ - Refresh React Context │
└────────────────────────────────────────────────────────────────┘
Performance Targets vs Implementation
| Requirement | Target | Implementation | Confidence |
|---|---|---|---|
| Drag & drop latence | < 100ms | Muuri + useOptimistic (sans revalidatePath) | ⭐⭐⭐⭐⭐ Validé en production |
| Recherche sémantique | < 300ms | Filtrage Prisma DB-level | ⭐⭐⭐⭐ Basé sur existant |
| Suggestions IA | < 2s | Vercel AI SDK (déjà en place) | ⭐⭐⭐⭐⭐ Validé en production |
| Sidebar chargement | < 200ms | React Context + useEffect | ⭐⭐⭐⭐ Standard React |
| Migration rollback | < 5s | Script Prisma + transaction | ⭐⭐⭐⭐ Basé sur DB size |
Risk Assessment
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| Performance dégradée avec 1000+ notes | HIGH | MEDIUM | Indexes Prisma, pagination, lazy loading |
| Drag & drop bug entre sidebar et grid | MEDIUM | LOW | Tests E2E, détection collision, fallback menu |
| IA suggestions incohérentes | MEDIUM | MEDIUM | Validation labels existants, feedback user |
| Migration data loss | CRITICAL | VERY LOW | Backup avant migration, rollback, tests staging |
| État global désynchronisé | MEDIUM | MEDIUM | React Context unique source of truth, revalidation |
Implementation Roadmap
Phase 1: Foundation (Week 1-2)
Objectif: Préparer le terrain sans toucher à l'existant
-
Database Schema
- Créer migration Prisma (Notebook, Label modifiés)
- Tester localement avec
prisma migrate dev - Valider indexes et cascade delete
-
State Management
- Créer
NotebooksContextetNotebooksProvider - Intégrer dans
app/providers.tsx - Créer hooks
useNotebooks()
- Créer
-
Base Server Actions
createNotebook(),updateNotebook(),deleteNotebook()createLabel(),updateLabel(),deleteLabel()moveNoteToNotebook()
Phase 2: Core UI (Week 2-3)
Objectif: Implémenter la sidebar et navigation
-
Notebooks Sidebar
- Composant
NotebooksSidebaravec Muuri - Drag & drop réorganisation notebooks
- "Notes générales" item
- Composant
-
Navigation
- Filtrer notes par notebook (Inbox vs Notebook)
- Mettre à jour masonry-grid pour afficher notes filtrées
- Breadcrumb/indicateur de notebook actuel
-
CRUD UI
- Modal création notebook
- Modal création label
- Menu contextuel (clic droit) sur notebooks
Phase 3: Drag & Drop Advanced (Week 3-4)
Objectif: Déplacer notes entre notebooks
-
Cross-container Drag & Drop
- Détecter drop sur sidebar
- Déplacer note vers notebook
- Déplacer note vers "Notes générales"
-
Menu Contextuel Alternative
- Clic droit → "Déplacer vers..."
- Liste des notebooks disponibles
- Fallback pour utilisateurs sans drag & drop
Phase 4: IA Integration (Week 4-5)
Objectif: Adapter l'IA existante pour le contexte
-
IA Services
ContextualAutoTagServiceNotebookSuggestionServiceNotebookSummaryService
-
UI IA Features
- IA1: Suggestion notebook quand note créée dans Inbox
- IA2: Suggestion labels contextuels
- IA5: Recherche sémantique limitée au notebook
Phase 5: Polish & Testing (Week 5-6)
Objectif: Finaliser et tester
-
Undo/Redo
- Implémenter
UndoHistory - Toast d'annulation
- Tester tous les cas de rollback
- Implémenter
-
Migration
- Script migration 4 phases
- Tests staging avec données réelles
- Documentation migration
-
Testing
- Tests E2E drag & drop
- Tests performance (1000+ notes)
- Tests IA suggestions
Conclusion
Architectural Principles Applied
- Brownfield-First - Réutilisation maximale de l'existant, ZERO breaking changes
- Single Responsibility - Chaque composant/service a une responsabilité claire
- Performance First - Optimisations DB-level, optimistic UI pour UX fluide
- Progressive Enhancement - Migration en 4 phases, adoption graduelle
- Simplicity over Complexity - Pas de Redux/Zustand/dnd-kit, utiliser le natif React
Key Success Factors
✅ Muuri pour tout drag & drop - Un seul library, UX cohérente ✅ React Context + useOptimistic - État gérable sans librairie externe ✅ Pattern Adapter pour IA - Réutilisation du code existant ✅ Migration graduelle - Pas de "big bang", rollback possible ✅ Zéro nouvelle dépendance majeure - 100% technologies existantes
Next Steps
- Valider ce document - Obtenir approbation architecturale
- Créer tech specs détaillées - Pour chaque composant majeur
- Lancer Phase 1 - Database schema + state management
- Itérer rapidement - 2 semaines par phase, validation continue
Document Status: ✅ COMPLETE & VALIDATED
Validation Date: 2026-01-11 Validated By: Ramez (Product Owner) Architect: Winston (AI Agent)
Total Architectural Decisions: 8 Total Components: ~10-12 New Dependencies: 0 Breaking Changes: 0 Estimated Implementation Time: 5-6 weeks
This architecture document was created collaboratively using the BMAD Architecture Workflow. Date: 2026-01-11 Architect: Winston (AI Agent) Project: Keep - Notebooks & Labels Contextuels