# Phase 2 - Pagination et Virtual Scrolling pour ObsiViewer ## 🎯 Objectif Implémenter la pagination curseur-based et le virtual scrolling pour permettre à ObsiViewer de gérer efficacement des vaults contenant **10,000+ fichiers** tout en maintenant des performances optimales. ## 📋 Contexte ### ✅ Ce qui a été accompli en Phase 1 - **Metadata-first loading** : Chargement ultra-rapide des métadonnées uniquement - **Lazy loading** : Contenu chargé à la demande lors de la sélection d'une note - **Optimisations serveur** : Cache intelligent et indexation différée - **Résultat** : 75% d'amélioration du temps de démarrage (15-25s → 2-4s) ### ❌ Limites de la Phase 1 - **Mémoire client** : Toutes les métadonnées (~1000 fichiers) chargées en mémoire - **Rendu UI** : Liste complète rendue même avec virtual scrolling partiel - **Scalabilité** : Performances dégradées au-delà de 1000 fichiers - **UX** : Scroll lag avec de gros volumes de données ### 🎯 Pourquoi la Phase 2 est nécessaire Pour supporter des vaults avec **10,000+ fichiers**, nous devons implémenter : 1. **Pagination côté serveur** : Charger les données par pages 2. **Virtual scrolling côté client** : Ne rendre que les éléments visibles 3. **Gestion intelligente de la mémoire** : Éviter de charger toutes les métadonnées ## 📊 Spécifications Techniques ### 1. Pagination Côté Serveur (Cursor-Based) #### Endpoint `/api/vault/metadata/paginated` ```typescript GET /api/vault/metadata/paginated?limit=100&cursor=500 Response: { "items": [ { "id": "note-1", "title": "Titre de la note", "filePath": "folder/note.md", "createdAt": "2025-01-01T00:00:00Z", "updatedAt": "2025-01-01T00:00:00Z" } // ... 99 autres items ], "nextCursor": "600", "hasMore": true, "total": 12500 } ``` #### Paramètres - **`limit`** : Nombre d'items par page (défaut: 100, max: 500) - **`cursor`** : Offset pour la pagination (défaut: 0) - **`search`** : Terme de recherche optionnel #### Implémentation Meilisearch ```typescript const result = await index.search(searchQuery, { limit: limit + 1, // +1 pour détecter s'il y a plus de résultats offset: parseInt(cursor) || 0, attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'] }); ``` ### 2. Virtual Scrolling Côté Client #### Composant NotesListComponent avec CDK Virtual Scrolling ```typescript import { ScrollingModule } from '@angular/cdk/scrolling'; @Component({ template: `
{{ note.title }}
` }) export class NotesListComponent { paginatedNotes = signal([]); currentPage = signal(0); hasMore = signal(true); // Charger plus de données lors du scroll async onScroll(index: number) { if (index > this.paginatedNotes().length - 50 && this.hasMore()) { await this.loadNextPage(); } } } ``` ### 3. Gestion d'État Client #### PaginationService ```typescript @Injectable({ providedIn: 'root' }) export class PaginationService { private pages = new Map(); private currentPage = signal(0); private totalItems = signal(0); // Cache des pages chargées getPage(page: number): NoteMetadata[] | undefined { return this.pages.get(page); } setPage(page: number, items: NoteMetadata[]) { this.pages.set(page, items); } // Invalider cache lors de changements invalidateCache() { this.pages.clear(); this.currentPage.set(0); } } ``` ## 🛠️ Plan d'Implémentation (2-3 jours) ### Jour 1 : Pagination Côté Serveur (4-6 heures) #### 1.1 Créer l'endpoint paginé **Fichier** : `server/index.mjs` **Lignes** : Après l'endpoint `/api/vault/metadata` ```javascript // NOUVEL ENDPOINT - Pagination curseur-based app.get('/api/vault/metadata/paginated', async (req, res) => { try { const limit = Math.min(parseInt(req.query.limit) || 100, 500); const cursor = req.query.cursor || '0'; const search = req.query.search || ''; console.time(`[Pagination] Load page cursor=${cursor}, limit=${limit}`); // Utiliser Meilisearch avec pagination const client = meiliClient(); const indexUid = vaultIndexName(vaultDir); const index = await ensureIndexSettings(client, indexUid); const result = await index.search(search, { limit: limit + 1, // +1 pour détecter s'il y a plus offset: parseInt(cursor), attributesToRetrieve: ['id', 'title', 'path', 'createdAt', 'updatedAt'], sort: ['updatedAt:desc'] // Trier par date de modification }); const hasMore = result.hits.length > limit; const items = result.hits.slice(0, limit); const nextCursor = hasMore ? (parseInt(cursor) + limit).toString() : null; // Convertir au format NoteMetadata const metadata = items.map(item => ({ id: item.id, title: item.title, filePath: item.path, createdAt: item.createdAt, updatedAt: item.updatedAt })); console.timeEnd(`[Pagination] Load page cursor=${cursor}, limit=${limit}`); res.json({ items: metadata, nextCursor, hasMore, total: result.estimatedTotalHits || result.hits.length }); } catch (error) { console.error('[Pagination] Error:', error); // Fallback: pagination simple sur filesystem try { const allMetadata = await getMetadataFromCache(); const offset = parseInt(cursor); const paginatedItems = allMetadata.slice(offset, offset + limit); const hasMore = offset + limit < allMetadata.length; res.json({ items: paginatedItems, nextCursor: hasMore ? (offset + limit).toString() : null, hasMore, total: allMetadata.length }); } catch (fallbackError) { res.status(500).json({ error: 'Pagination failed' }); } } }); ``` #### 1.2 Mettre à jour le cache pour supporter la pagination **Fichier** : `server/performance-config.mjs` ```javascript export class MetadataCache { constructor() { this.metadata = null; this.lastUpdate = 0; this.ttl = 5 * 60 * 1000; // 5 minutes } async getMetadata() { const now = Date.now(); if (this.metadata && (now - this.lastUpdate) < this.ttl) { return this.metadata; } // Recharger depuis Meilisearch ou filesystem this.metadata = await loadVaultMetadataOnly(process.env.VAULT_PATH); this.lastUpdate = now; return this.metadata; } invalidate() { this.metadata = null; this.lastUpdate = 0; } } ``` #### 1.3 Tests de l'endpoint paginé ```bash # Test pagination simple curl "http://localhost:4000/api/vault/metadata/paginated?limit=10" # Test avec curseur curl "http://localhost:4000/api/vault/metadata/paginated?limit=10&cursor=10" # Test avec recherche curl "http://localhost:4000/api/vault/metadata/paginated?limit=10&search=projet" ``` ### Jour 2 : Virtual Scrolling Côté Client (4-6 heures) #### 2.1 Créer PaginationService **Fichier** : `src/app/services/pagination.service.ts` ```typescript import { Injectable, signal, computed } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { firstValueFrom } from 'rxjs'; export interface NoteMetadata { id: string; title: string; filePath: string; createdAt: string; updatedAt: string; } export interface PaginationResponse { items: NoteMetadata[]; nextCursor: string | null; hasMore: boolean; total: number; } @Injectable({ providedIn: 'root' }) export class PaginationService { private http = inject(HttpClient); // État de la pagination private pages = signal>(new Map()); private currentCursor = signal(null); private hasMorePages = signal(true); private isLoading = signal(false); private searchTerm = signal(''); // Liste concaténée de toutes les pages chargées readonly allItems = computed(() => { const pages = this.pages(); const result: NoteMetadata[] = []; for (const page of pages.values()) { result.push(...page); } return result; }); readonly totalLoaded = computed(() => this.allItems().length); readonly canLoadMore = computed(() => this.hasMorePages() && !this.isLoading()); // Charger la première page async loadInitial(search = ''): Promise { this.searchTerm.set(search); this.pages.set(new Map()); this.currentCursor.set(null); this.hasMorePages.set(true); await this.loadNextPage(); } // Charger la page suivante async loadNextPage(): Promise { if (this.isLoading() || !this.hasMorePages()) return; this.isLoading.set(true); try { const params: any = { limit: 100, search: this.searchTerm() }; if (this.currentCursor()) { params.cursor = this.currentCursor(); } const response = await firstValueFrom( this.http.get('/api/vault/metadata/paginated', { params }) ); // Ajouter la page au cache const pageIndex = this.pages().size; this.pages.update(pages => { const newPages = new Map(pages); newPages.set(pageIndex, response.items); return newPages; }); // Mettre à jour l'état this.currentCursor.set(response.nextCursor); this.hasMorePages.set(response.hasMore); } catch (error) { console.error('[PaginationService] Failed to load page:', error); throw error; } finally { this.isLoading.set(false); } } // Rechercher avec un nouveau terme async search(term: string): Promise { await this.loadInitial(term); } // Invalider le cache (après modifications) invalidateCache(): void { this.pages.set(new Map()); this.currentCursor.set(null); this.hasMorePages.set(true); } } ``` #### 2.2 Mettre à jour NotesListComponent avec virtual scrolling **Fichier** : `src/app/features/list/notes-list.component.ts` ```typescript import { Component, inject, signal, computed, OnInit, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling'; import { PaginationService, NoteMetadata } from '../../services/pagination.service'; import { Router } from '@angular/router'; @Component({ selector: 'app-notes-list', standalone: true, imports: [CommonModule, ScrollingModule], template: `
{{ note.title }}
{{ getRelativePath(note.filePath) }} {{ formatDate(note.updatedAt) }}
Chargement...
{{ totalLoaded() }} notes chargées
`, styles: [` .notes-list-container { min-height: 400px; } .note-item { min-height: 60px; display: flex; flex-direction: column; justify-content: center; } .note-item.selected { background-color: var(--surface1); border-left: 3px solid var(--primary); } .cdk-virtual-scroll-viewport { height: 100%; } `] }) export class NotesListComponent implements OnInit, OnDestroy { private paginationService = inject(PaginationService); private router = inject(Router); // État local selectedNoteId = signal(null); // Données paginées paginatedNotes = this.paginationService.allItems; isLoadingMore = this.paginationService.isLoading; hasMorePages = this.paginationService.hasMorePages; totalLoaded = this.paginationService.totalLoaded; canLoadMore = this.paginationService.canLoadMore; // Subscription pour les changements de recherche private searchSubscription?: Subscription; ngOnInit() { // Charger la première page this.paginationService.loadInitial(); // Écouter les changements de recherche depuis le parent // (à connecter au composant parent) } ngOnDestroy() { this.searchSubscription?.unsubscribe(); } // Gestion du scroll virtuel onScroll(index: number) { // Charger plus de données quand on approche de la fin if (index > this.paginatedNotes().length - 20 && this.canLoadMore()) { this.paginationService.loadNextPage(); } } // Sélection d'une note async selectNote(note: NoteMetadata) { this.selectedNoteId.set(note.id); // Naviguer vers la note (lazy loading du contenu) await this.router.navigate(['/note', note.id]); } // Utilitaires trackByFn(index: number, item: NoteMetadata): string { return item.id; } getRelativePath(filePath: string): string { // Extraire le chemin relatif depuis le vault return filePath.replace(/^.*?\//, ''); } formatDate(dateString: string): string { const date = new Date(dateString); const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffDays === 0) return 'aujourd\'hui'; if (diffDays === 1) return 'hier'; if (diffDays < 7) return `il y a ${diffDays}j`; return date.toLocaleDateString('fr-FR', { month: 'short', day: 'numeric' }); } // Méthode pour recevoir les changements de recherche onSearchChange(searchTerm: string) { this.paginationService.search(searchTerm); } } ``` #### 2.3 Intégrer la recherche **Modification** : Mettre à jour le composant parent pour connecter la recherche ```typescript // Dans le composant parent (ex: sidebar) export class SidebarComponent { private paginationService = inject(PaginationService); onSearchInput(event: Event) { const searchTerm = (event.target as HTMLInputElement).value; this.paginationService.search(searchTerm); } } ``` ### Jour 3 : Intégration et Tests (4-6 heures) #### 3.1 Mettre à jour les composants parents - Modifier `SidebarComponent` pour utiliser `NotesListComponent` avec pagination - Connecter la recherche au `PaginationService` - Gérer les événements de sélection de notes #### 3.2 Gestion des événements de fichiers **Fichier** : `src/app/services/vault-events.service.ts` ```typescript // Invalider le cache de pagination lors de changements private handleFileChange(event: VaultEvent) { switch (event.type) { case 'add': case 'change': case 'unlink': this.paginationService.invalidateCache(); // Recharger la première page this.paginationService.loadInitial(); break; } } ``` #### 3.3 Tests de performance **Fichier** : `scripts/test-pagination.mjs` ```javascript #!/usr/bin/env node const BASE_URL = process.env.BASE_URL || 'http://localhost:4000'; async function testPagination() { console.log('🧪 Testing Pagination Performance\n'); // Test 1: Première page console.log('📄 Test 1: Loading first page...'); const start1 = performance.now(); const response1 = await fetch(`${BASE_URL}/api/vault/metadata/paginated?limit=50`); const data1 = await response1.json(); const time1 = performance.now() - start1; console.log(`✅ First page: ${data1.items.length} items in ${time1.toFixed(2)}ms`); console.log(`📊 Total available: ${data1.total} items\n`); // Test 2: Pagination complète (simuler scroll) console.log('📜 Test 2: Simulating scroll through 5 pages...'); let totalTime = 0; let totalItems = 0; let cursor = null; for (let page = 0; page < 5; page++) { const start = performance.now(); const params = new URLSearchParams({ limit: '50' }); if (cursor) params.set('cursor', cursor); const response = await fetch(`${BASE_URL}/api/vault/metadata/paginated?${params}`); const data = await response.json(); const time = performance.now() - start; totalTime += time; totalItems += data.items.length; cursor = data.nextCursor; console.log(` Page ${page + 1}: ${data.items.length} items in ${time.toFixed(2)}ms`); if (!data.hasMore) break; } console.log(`\n📊 Pagination Results:`); console.log(` Total items loaded: ${totalItems}`); console.log(` Total time: ${totalTime.toFixed(2)}ms`); console.log(` Average per page: ${(totalTime / 5).toFixed(2)}ms`); console.log(` Memory efficient: Only ${totalItems} items in memory`); } testPagination().catch(console.error); ``` #### 3.4 Tests d'intégration - Tester avec un vault de 10,000+ fichiers - Vérifier le virtual scrolling - Tester la recherche paginée - Mesurer les performances mémoire ## ✅ Critères d'Acceptation ### Fonctionnels - [ ] **Pagination serveur** : Endpoint retourne des pages de 100 items max - [ ] **Curseur-based** : Navigation correcte avec curseurs - [ ] **Virtual scrolling** : Seuls les éléments visibles sont rendus - [ ] **Recherche** : Recherche fonctionne avec pagination - [ ] **Lazy loading** : Contenu chargé à la demande (hérité de Phase 1) ### Performances - [ ] **Temps de première page** : < 500ms - [ ] **Temps de pages suivantes** : < 300ms - [ ] **Mémoire client** : < 50MB pour 10,000+ fichiers - [ ] **Scroll fluide** : 60fps minimum - [ ] **Recherche** : < 200ms pour résultats paginés ### UX - [ ] **Scroll infini** : Chargement automatique lors du scroll - [ ] **Indicateurs** : Loading states et "fin de liste" - [ ] **Sélection** : Navigation vers notes préserve l'état - [ ] **Responsive** : Fonctionne sur mobile et desktop ### Robustesse - [ ] **Cache invalidation** : Mise à jour lors de changements de fichiers - [ ] **Erreur handling** : Fallback gracieux en cas d'erreur - [ ] **Rétrocompatibilité** : Anciens endpoints toujours fonctionnels - [ ] **Meilisearch fallback** : Fonctionne sans recherche avancée ## 📊 Métriques de Succès ### Avant Phase 2 (avec Phase 1) ``` Vault de 1,000 fichiers: - Mémoire: 50-100MB - Temps d'affichage: 2-4s - Scroll: Lag au-delà de 500 items ``` ### Après Phase 2 ``` Vault de 10,000 fichiers: - Mémoire: 5-10MB (90% de réduction) - Temps d'affichage: 1-2s (50% plus rapide) - Scroll: Fluide à 60fps - Pagination: Chargement par pages de 100 items ``` ### Tests de Charge - **10,000 fichiers** : Mémoire < 50MB, scroll fluide - **50,000 fichiers** : Mémoire < 100MB, pagination fonctionnelle - **100,000+ fichiers** : Support théorique illimité ## 🔧 Dépendances et Prérequis ### Dépendances Techniques - **Angular CDK** : Pour virtual scrolling (`@angular/cdk/scrolling`) - **Meilisearch** : Pour recherche paginée (recommandé) - **Node.js 18+** : Pour fetch API natif ### Prérequis - ✅ **Phase 1 terminée** : Metadata-first loading opérationnel - ✅ **Cache serveur** : Implémentation du cache de métadonnées - ✅ **Lazy loading** : Contenu chargé à la demande ## 🚨 Points d'Attention ### Performance 1. **Taille d'itemSize** : 60px est optimal pour la plupart des notes 2. **Seuil de chargement** : Précharger 20-30 items avant la fin visible 3. **Cache de pages** : Garder 3-5 pages en mémoire pour le scroll rapide ### UX 1. **Indicateurs visuels** : Montrer clairement le chargement 2. **États d'erreur** : Gestion gracieuse des échecs de chargement 3. **Scroll to top** : Bouton pour revenir en haut de longues listes ### Robustesse 1. **Connexion réseau** : Retry automatique en cas d'échec 2. **Cache stale** : Invalidation intelligente du cache 3. **Memory leaks** : Nettoyer les subscriptions et caches ## 🧪 Plan de Test ### Tests Unitaires ```typescript // PaginationService tests describe('PaginationService', () => { it('should load first page', async () => { // Test chargement initial }); it('should load next page on scroll', async () => { // Test pagination automatique }); it('should handle search correctly', async () => { // Test recherche avec pagination }); }); ``` ### Tests d'Intégration ```typescript // End-to-end tests describe('Pagination E2E', () => { it('should display first 100 notes', () => { // Vérifier affichage initial }); it('should load more on scroll', () => { // Simuler scroll et vérifier chargement }); it('should handle large vault (10k+ files)', () => { // Test avec gros volume }); }); ``` ### Tests de Performance ```bash # Script de benchmark npm run test:pagination-performance # Résultats attendus: # - First page: < 500ms # - Subsequent pages: < 300ms # - Memory: < 50MB for 10k files ``` ## 🎯 Livrables ### Code - ✅ Endpoint `/api/vault/metadata/paginated` - ✅ `PaginationService` avec cache intelligent - ✅ `NotesListComponent` avec virtual scrolling - ✅ Intégration avec recherche existante ### Documentation - ✅ Guide d'implémentation détaillé - ✅ Tests automatisés - ✅ Métriques de performance - ✅ Guide de maintenance ### Tests - ✅ Tests unitaires pour services - ✅ Tests d'intégration pour composants - ✅ Tests de performance avec gros volumes - ✅ Tests de régression --- ## 🚀 Résumé La Phase 2 transforme ObsiViewer en une application capable de gérer des vaults de **taille illimitée** avec des performances constantes. L'approche **pagination + virtual scrolling** permet de maintenir une UX fluide même avec 100,000+ fichiers. **Effort** : 2-3 jours **Risque** : Faible **Impact** : Support illimité de fichiers **ROI** : Transformation complète de la scalabilité **Prêt pour implémentation ! 🎯**