779 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
			
		
		
	
	
			779 lines
		
	
	
		
			23 KiB
		
	
	
	
		
			Markdown
		
	
	
	
	
	
| # 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: `
 | |
|     <cdk-virtual-scroll-viewport 
 | |
|       itemSize="60" 
 | |
|       class="h-full"
 | |
|       (scrolledIndexChange)="onScroll($event)">
 | |
|       
 | |
|       <div *cdkVirtualFor="let note of paginatedNotes; trackBy: trackByFn"
 | |
|            class="note-item"
 | |
|            [class.selected]="note.id === selectedNoteId()">
 | |
|         {{ note.title }}
 | |
|       </div>
 | |
|     </cdk-virtual-scroll-viewport>
 | |
|   `
 | |
| })
 | |
| export class NotesListComponent {
 | |
|   paginatedNotes = signal<NoteMetadata[]>([]);
 | |
|   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<number, NoteMetadata[]>();
 | |
|   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<Map<number, NoteMetadata[]>>(new Map());
 | |
|   private currentCursor = signal<string | null>(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<void> {
 | |
|     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<void> {
 | |
|     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<PaginationResponse>('/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<void> {
 | |
|     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: `
 | |
|     <div class="notes-list-container h-full">
 | |
|       <!-- Virtual Scroll Viewport -->
 | |
|       <cdk-virtual-scroll-viewport 
 | |
|         itemSize="60" 
 | |
|         class="h-full"
 | |
|         (scrolledIndexChange)="onScroll($event)">
 | |
|         
 | |
|         <div class="notes-list">
 | |
|           <!-- Items virtuels -->
 | |
|           <div 
 | |
|             *cdkVirtualFor="let note of paginatedNotes(); trackBy: trackByFn"
 | |
|             class="note-item p-3 border-b border-surface2 hover:bg-surface1 cursor-pointer transition-colors"
 | |
|             [class.selected]="note.id === selectedNoteId()"
 | |
|             (click)="selectNote(note)">
 | |
|             
 | |
|             <div class="note-title truncate font-medium">
 | |
|               {{ note.title }}
 | |
|             </div>
 | |
|             
 | |
|             <div class="note-meta text-xs text-muted mt-1 flex justify-between">
 | |
|               <span class="note-path truncate opacity-60">
 | |
|                 {{ getRelativePath(note.filePath) }}
 | |
|               </span>
 | |
|               <span class="note-date opacity-60">
 | |
|                 {{ formatDate(note.updatedAt) }}
 | |
|               </span>
 | |
|             </div>
 | |
|           </div>
 | |
|           
 | |
|           <!-- Loading indicator -->
 | |
|           <div *ngIf="isLoadingMore()" class="p-4 text-center text-muted">
 | |
|             <div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
 | |
|             <span class="ml-2">Chargement...</span>
 | |
|           </div>
 | |
|           
 | |
|           <!-- End of list indicator -->
 | |
|           <div *ngIf="!hasMorePages() && totalLoaded() > 0" class="p-4 text-center text-muted">
 | |
|             {{ totalLoaded() }} notes chargées
 | |
|           </div>
 | |
|         </div>
 | |
|       </cdk-virtual-scroll-viewport>
 | |
|     </div>
 | |
|   `,
 | |
|   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<string | null>(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 ! 🎯** |