ObsiViewer/docs/PERFORMENCE/phase2/prompt-Grok_Fast1.md

23 KiB

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

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

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

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

@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

// 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

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é

# 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

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

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

// 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

// 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

#!/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

// 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

// 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

# 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 ! 🎯