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