1724 lines
		
	
	
		
			58 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			1724 lines
		
	
	
		
			58 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| /**
 | |
|  * NOTYTEX - Class Dashboard Module
 | |
|  * Gestion avancée du dashboard de classe avec navigation par trimestre
 | |
|  * et progressive disclosure des statistiques
 | |
|  */
 | |
| 
 | |
| class ClassDashboard {
 | |
|     constructor(classId, options = {}) {
 | |
|         this.classId = classId;
 | |
|         this.options = {
 | |
|             debounceTime: 300,
 | |
|             cacheTimeout: 5 * 60 * 1000, // 5 minutes
 | |
|             animationDuration: Notytex.config.transitions.normal,
 | |
|             enableTouchGestures: true,
 | |
|             ...options
 | |
|         };
 | |
| 
 | |
|         // État centralisé
 | |
|         this.state = {
 | |
|             currentTrimester: null,
 | |
|             expandedCards: new Set(),
 | |
|             cache: new Map(),
 | |
|             loading: false,
 | |
|             touchStartX: null,
 | |
|             touchStartY: null,
 | |
|             touchStartTime: null,
 | |
|             swipeDirection: null,
 | |
|             isInitialized: false,
 | |
|             currentDevice: this.detectDevice(),
 | |
|             intersectionObserver: null,
 | |
|             performanceMetrics: {
 | |
|                 cls: 0,
 | |
|                 lcp: 0,
 | |
|                 fid: 0
 | |
|             }
 | |
|         };
 | |
| 
 | |
|         // Éléments DOM cachés
 | |
|         this.elements = {};
 | |
|         
 | |
|         this.init();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Initialisation du dashboard
 | |
|      */
 | |
|     async init() {
 | |
|         try {
 | |
|             this.cacheElements();
 | |
|             this.restoreStateFromURL();
 | |
|             this.setupResponsiveBehavior();
 | |
|             this.setupIntersectionObserver();
 | |
|             this.setupPerformanceMonitoring();
 | |
|             await this.loadInitialData();
 | |
|             this.bindEvents();
 | |
|             this.setupAccessibility();
 | |
|             this.setupAdvancedTouchGestures();
 | |
|             this.state.isInitialized = true;
 | |
|             
 | |
|             // Animer l'apparition du contenu
 | |
|             this.animateInitialLoad();
 | |
|             
 | |
|             // Précharger les données intelligemment
 | |
|             this.setupSmartPrefetching();
 | |
|             
 | |
|         } catch (error) {
 | |
|             console.error('Erreur lors de l\'initialisation du ClassDashboard:', error);
 | |
|             this.showError('Erreur lors du chargement du dashboard');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Cache les références aux éléments DOM
 | |
|      */
 | |
|     cacheElements() {
 | |
|         this.elements = {
 | |
|             container: document.querySelector('[data-class-dashboard]'),
 | |
|             trimesterTabs: document.querySelectorAll('[data-trimester-tab]'),
 | |
|             statsCards: document.querySelectorAll('[data-stats-card]'),
 | |
|             loadingOverlay: document.querySelector('[data-loading-overlay]'),
 | |
|             errorContainer: document.querySelector('[data-error-container]'),
 | |
|             statsContent: document.querySelector('[data-stats-content]')
 | |
|         };
 | |
| 
 | |
|         if (!this.elements.container) {
 | |
|             throw new Error('Conteneur du dashboard non trouvé');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Restaure l'état depuis l'URL
 | |
|      */
 | |
|     restoreStateFromURL() {
 | |
|         const params = new URLSearchParams(window.location.search);
 | |
|         
 | |
|         // Trimestre depuis l'URL
 | |
|         const trimesterParam = params.get('trimestre');
 | |
|         if (trimesterParam) {
 | |
|             const trimester = parseInt(trimesterParam);
 | |
|             if ([1, 2, 3].includes(trimester)) {
 | |
|                 this.state.currentTrimester = trimester;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Cards expandées depuis l'URL
 | |
|         const expandedParam = params.get('expanded');
 | |
|         if (expandedParam) {
 | |
|             expandedParam.split(',').forEach(cardType => {
 | |
|                 this.state.expandedCards.add(cardType);
 | |
|             });
 | |
|         }
 | |
| 
 | |
|         // Mettre à jour l'UI avec l'état restauré
 | |
|         this.updateTrimesterTabsUI();
 | |
|         this.updateExpandedCardsUI();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Charge les données initiales
 | |
|      */
 | |
|     async loadInitialData() {
 | |
|         const trimester = this.state.currentTrimester;
 | |
|         return await this.fetchStats(trimester);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Liaison des événements
 | |
|      */
 | |
|     bindEvents() {
 | |
|         // Navigation par trimestre
 | |
|         this.elements.trimesterTabs.forEach(tab => {
 | |
|             tab.addEventListener('click', this.handleTrimesterClick.bind(this));
 | |
|         });
 | |
| 
 | |
|         // Les cartes affichent maintenant toutes leurs données directement
 | |
|         // Plus besoin d'event listeners pour expand/collapse
 | |
| 
 | |
|         // Navigation clavier
 | |
|         document.addEventListener('keydown', this.handleKeyboardNavigation.bind(this));
 | |
| 
 | |
|         // Gestion du redimensionnement
 | |
|         const debouncedResize = Notytex.utils.debounce(
 | |
|             this.handleResize.bind(this), 
 | |
|             this.options.debounceTime
 | |
|         );
 | |
|         window.addEventListener('resize', debouncedResize);
 | |
| 
 | |
|         // Touch gestures pour mobile
 | |
|         if (this.options.enableTouchGestures && this.isMobile()) {
 | |
|             this.setupTouchGestures();
 | |
|         }
 | |
| 
 | |
|         // Gestion du back/forward du navigateur
 | |
|         window.addEventListener('popstate', this.handlePopState.bind(this));
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Configuration de l'accessibilité
 | |
|      */
 | |
|     setupAccessibility() {
 | |
|         // ARIA labels pour les tabs
 | |
|         this.elements.trimesterTabs.forEach((tab, index) => {
 | |
|             tab.setAttribute('role', 'tab');
 | |
|             tab.setAttribute('aria-selected', 'false');
 | |
|             tab.setAttribute('tabindex', index === 0 ? '0' : '-1');
 | |
|         });
 | |
| 
 | |
|         // ARIA pour les cards
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             const header = card.querySelector('[data-card-header]');
 | |
|             const content = card.querySelector('[data-card-content]');
 | |
|             
 | |
|             if (header && content) {
 | |
|                 const cardId = `card-${Math.random().toString(36).substr(2, 9)}`;
 | |
|                 header.setAttribute('aria-expanded', 'false');
 | |
|                 header.setAttribute('aria-controls', cardId);
 | |
|                 content.setAttribute('id', cardId);
 | |
|                 content.setAttribute('aria-hidden', 'true');
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion des clics sur les onglets trimestre
 | |
|      */
 | |
|     async handleTrimesterClick(event) {
 | |
|         event.preventDefault();
 | |
|         
 | |
|         const tab = event.currentTarget;
 | |
|         const trimester = tab.dataset.trimesterTab;
 | |
|         const trimesterValue = trimester === 'global' ? null : parseInt(trimester);
 | |
| 
 | |
|         if (trimesterValue === this.state.currentTrimester) {
 | |
|             return; // Déjà sélectionné
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             await this.changeTrimester(trimesterValue);
 | |
|         } catch (error) {
 | |
|             console.error('Erreur lors du changement de trimestre:', error);
 | |
|             this.showError('Erreur lors du changement de trimestre');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Changement de trimestre avec animation
 | |
|      */
 | |
|     async changeTrimester(trimester) {
 | |
|         // Mise à jour de l'état
 | |
|         this.state.currentTrimester = trimester;
 | |
| 
 | |
|         // Animation de transition
 | |
|         await this.animateTrimesterTransition();
 | |
| 
 | |
|         // Chargement des nouvelles données
 | |
|         await this.fetchStats(trimester);
 | |
| 
 | |
|         // Mise à jour de l'UI
 | |
|         this.updateTrimesterTabsUI();
 | |
|         this.updateURL();
 | |
| 
 | |
|         // Notification de succès
 | |
|         const trimesterName = trimester ? `Trimestre ${trimester}` : 'Vue globale';
 | |
|         Notytex.utils.showToast(`${trimesterName} chargé`, 'success', 1500);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion des clics sur les headers de cards
 | |
|      */
 | |
|     handleCardClick(event) {
 | |
|         event.preventDefault();
 | |
|         
 | |
|         const header = event.currentTarget;
 | |
|         const card = header.closest('[data-stats-card]');
 | |
|         const cardType = card.dataset.statsCard;
 | |
| 
 | |
|         this.toggleCard(cardType);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Toggle d'une card avec animation
 | |
|      */
 | |
|     async toggleCard(cardType) {
 | |
|         const card = document.querySelector(`[data-stats-card="${cardType}"]`);
 | |
|         if (!card) return;
 | |
| 
 | |
|         const isExpanded = this.state.expandedCards.has(cardType);
 | |
|         
 | |
|         if (isExpanded) {
 | |
|             await this.collapseCard(cardType);
 | |
|             this.state.expandedCards.delete(cardType);
 | |
|         } else {
 | |
|             await this.expandCard(cardType);
 | |
|             this.state.expandedCards.add(cardType);
 | |
|         }
 | |
| 
 | |
|         this.updateURL();
 | |
|         this.updateCardAccessibility(cardType, !isExpanded);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Expansion d'une card
 | |
|      */
 | |
|     async expandCard(cardType) {
 | |
|         const card = document.querySelector(`[data-stats-card="${cardType}"]`);
 | |
|         const content = card.querySelector('[data-card-content]');
 | |
|         const icon = card.querySelector('[data-expand-icon]');
 | |
| 
 | |
|         // Rotation de l'icône
 | |
|         if (icon) {
 | |
|             icon.style.transform = 'rotate(180deg)';
 | |
|         }
 | |
| 
 | |
|         // Animation d'expansion
 | |
|         content.style.display = 'block';
 | |
|         content.style.height = '0px';
 | |
|         content.style.opacity = '0';
 | |
| 
 | |
|         // Force reflow
 | |
|         content.offsetHeight;
 | |
| 
 | |
|         const targetHeight = content.scrollHeight;
 | |
|         
 | |
|         content.style.transition = `height ${this.options.animationDuration}ms ease-in-out, opacity ${this.options.animationDuration}ms ease-in-out`;
 | |
|         content.style.height = `${targetHeight}px`;
 | |
|         content.style.opacity = '1';
 | |
| 
 | |
|         // Cleanup après animation
 | |
|         setTimeout(() => {
 | |
|             content.style.height = 'auto';
 | |
|             content.style.transition = '';
 | |
|         }, this.options.animationDuration);
 | |
| 
 | |
|         // Charger le contenu détaillé si nécessaire
 | |
|         await this.loadCardDetailedContent(cardType);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Collapse d'une card
 | |
|      */
 | |
|     async collapseCard(cardType) {
 | |
|         const card = document.querySelector(`[data-stats-card="${cardType}"]`);
 | |
|         const content = card.querySelector('[data-card-content]');
 | |
|         const icon = card.querySelector('[data-expand-icon]');
 | |
| 
 | |
|         // Rotation de l'icône
 | |
|         if (icon) {
 | |
|             icon.style.transform = 'rotate(0deg)';
 | |
|         }
 | |
| 
 | |
|         // Animation de collapse
 | |
|         const currentHeight = content.offsetHeight;
 | |
|         content.style.height = `${currentHeight}px`;
 | |
|         content.style.transition = `height ${this.options.animationDuration}ms ease-in-out, opacity ${this.options.animationDuration}ms ease-in-out`;
 | |
| 
 | |
|         // Force reflow
 | |
|         content.offsetHeight;
 | |
| 
 | |
|         content.style.height = '0px';
 | |
|         content.style.opacity = '0';
 | |
| 
 | |
|         // Cleanup après animation
 | |
|         setTimeout(() => {
 | |
|             content.style.display = 'none';
 | |
|             content.style.height = '';
 | |
|             content.style.opacity = '';
 | |
|             content.style.transition = '';
 | |
|         }, this.options.animationDuration);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Chargement des données statistiques
 | |
|      */
 | |
|     async fetchStats(trimester) {
 | |
|         const cacheKey = `stats-${this.classId}-${trimester || 'global'}`;
 | |
|         
 | |
|         // Vérifier le cache
 | |
|         if (this.isCacheValid(cacheKey)) {
 | |
|             const cachedData = this.state.cache.get(cacheKey);
 | |
|             this.updateStatsUI(cachedData.data);
 | |
|             return cachedData.data;
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             this.showLoading();
 | |
| 
 | |
|             const url = trimester 
 | |
|                 ? `/classes/${this.classId}/stats?trimestre=${trimester}`
 | |
|                 : `/classes/${this.classId}/stats`;
 | |
| 
 | |
|             const response = await fetch(url, {
 | |
|                 method: 'GET',
 | |
|                 headers: {
 | |
|                     'Accept': 'application/json',
 | |
|                     'X-Requested-With': 'XMLHttpRequest'
 | |
|                 }
 | |
|             });
 | |
| 
 | |
|             if (!response.ok) {
 | |
|                 throw new Error(`Erreur HTTP: ${response.status}`);
 | |
|             }
 | |
| 
 | |
|             const data = await response.json();
 | |
|             
 | |
|             // Mise en cache avec timestamp
 | |
|             this.state.cache.set(cacheKey, {
 | |
|                 data: data,
 | |
|                 timestamp: Date.now()
 | |
|             });
 | |
| 
 | |
|             this.updateStatsUI(data);
 | |
|             return data;
 | |
| 
 | |
|         } catch (error) {
 | |
|             console.error('Erreur lors du chargement des statistiques:', error);
 | |
|             this.showError('Impossible de charger les statistiques');
 | |
|             throw error;
 | |
|         } finally {
 | |
|             this.hideLoading();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Chargement du contenu détaillé d'une card
 | |
|      */
 | |
|     async loadCardDetailedContent(cardType) {
 | |
|         const card = document.querySelector(`[data-stats-card="${cardType}"]`);
 | |
|         const detailContainer = card.querySelector('[data-detail-content]');
 | |
|         
 | |
|         if (!detailContainer || detailContainer.dataset.loaded === 'true') {
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         try {
 | |
|             // Skeleton loading
 | |
|             detailContainer.innerHTML = this.createSkeletonHTML(cardType);
 | |
| 
 | |
|             // Pour l'instant, nous n'avons pas de contenu détaillé spécialisé
 | |
|             // Les données sont déjà affichées dans les cards principales
 | |
|             detailContainer.innerHTML = '<div class="text-center text-gray-500 text-sm py-2">Détails supplémentaires bientôt disponibles</div>';
 | |
|             detailContainer.dataset.loaded = 'true';
 | |
| 
 | |
|         } catch (error) {
 | |
|             console.error(`Erreur lors du chargement des détails ${cardType}:`, error);
 | |
|             detailContainer.innerHTML = '<p class="text-red-600 text-sm">Erreur lors du chargement</p>';
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'interface utilisateur avec les nouvelles données
 | |
|      */
 | |
|     updateStatsUI(statsData) {
 | |
|         // Mise à jour des cards de domaines
 | |
|         this.updateDomainsCard(statsData.domains);
 | |
|         
 | |
|         // Mise à jour des cards de compétences
 | |
|         this.updateCompetencesCard(statsData.competences);
 | |
|         
 | |
|         // Mise à jour des résultats
 | |
|         if (statsData.results) {
 | |
|             this.updateResultsCard(statsData.results);
 | |
|         }
 | |
| 
 | |
|         // Animation d'apparition
 | |
|         this.animateStatsUpdate();
 | |
|     }
 | |
| 
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de la card domaines
 | |
|      */
 | |
|     updateDomainsCard(domainsData) {
 | |
|         const card = document.querySelector('[data-stats-card="domains"]');
 | |
|         if (!card || !domainsData) return;
 | |
| 
 | |
|         // Mettre à jour le compteur
 | |
|         const countElement = card.querySelector('[data-domains-count]');
 | |
|         if (countElement) {
 | |
|             this.animateNumber(countElement, domainsData.length);
 | |
|         }
 | |
| 
 | |
|         // Mettre à jour la liste
 | |
|         const container = card.querySelector('[data-domains-list]');
 | |
|         if (!container) return;
 | |
| 
 | |
|         if (domainsData.length === 0) {
 | |
|             container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucun domaine évalué</div>';
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         container.innerHTML = domainsData.map(domain => `
 | |
|             <div class="bg-green-50 rounded-lg p-3 border border-green-100">
 | |
|                 <div class="flex items-center justify-between mb-1">
 | |
|                     <div class="flex items-center">
 | |
|                         <div class="w-3 h-3 rounded-full mr-3" style="background-color: ${domain.color}"></div>
 | |
|                         <span class="text-green-800 font-medium">${domain.name}</span>
 | |
|                     </div>
 | |
|                     <span class="text-green-900 font-bold text-sm">${domain.total_points}pts</span>
 | |
|                 </div>
 | |
|                 <div class="text-xs text-green-600 pl-6">
 | |
|                     ${domain.elements_count} ${domain.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
 | |
|                 </div>
 | |
|             </div>
 | |
|         `).join('');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de la card compétences
 | |
|      */
 | |
|     updateCompetencesCard(competencesData) {
 | |
|         const card = document.querySelector('[data-stats-card="competences"]');
 | |
|         if (!card || !competencesData) return;
 | |
| 
 | |
|         // Mettre à jour le compteur
 | |
|         const countElement = card.querySelector('[data-competences-count]');
 | |
|         if (countElement) {
 | |
|             this.animateNumber(countElement, competencesData.length);
 | |
|         }
 | |
| 
 | |
|         // Mettre à jour la liste
 | |
|         const container = card.querySelector('[data-competences-list]');
 | |
|         if (!container) return;
 | |
| 
 | |
|         if (competencesData.length === 0) {
 | |
|             container.innerHTML = '<div class="text-center text-gray-500 text-sm py-4">Aucune compétence évaluée</div>';
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         container.innerHTML = competencesData.map(competence => `
 | |
|             <div class="bg-purple-50 rounded-lg p-3 border border-purple-100">
 | |
|                 <div class="flex items-center justify-between mb-1">
 | |
|                     <div class="flex items-center">
 | |
|                         <div class="w-3 h-3 rounded-full mr-3" style="background-color: ${competence.color}"></div>
 | |
|                         <span class="text-purple-800 font-medium">${competence.name}</span>
 | |
|                     </div>
 | |
|                     <span class="text-purple-900 font-bold text-sm">${competence.total_points}pts</span>
 | |
|                 </div>
 | |
|                 <div class="text-xs text-purple-600 pl-6">
 | |
|                     ${competence.elements_count} ${competence.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
 | |
|                 </div>
 | |
|             </div>
 | |
|         `).join('');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de la card résultats
 | |
|      */
 | |
|     updateResultsCard(resultsData) {
 | |
|         const card = document.querySelector('[data-stats-card="results"]');
 | |
|         if (!card || !resultsData) return;
 | |
| 
 | |
|         const statsMappings = {
 | |
|             'mean': 'average',      // L'API renvoie "average" mais le DOM attend "mean"
 | |
|             'median': 'median',
 | |
|             'std_dev': 'std_dev',
 | |
|             'min': 'min',
 | |
|             'max': 'max',
 | |
|             'assessments_count': 'assessments_count'
 | |
|         };
 | |
| 
 | |
|         Object.entries(statsMappings).forEach(([domKey, apiKey]) => {
 | |
|             const element = card.querySelector(`[data-result="${domKey}"]`);
 | |
|             if (element && resultsData[apiKey] !== undefined) {
 | |
|                 let value;
 | |
|                 if (domKey === 'assessments_count') {
 | |
|                     // Format spécial pour le nombre d'évaluations
 | |
|                     const count = resultsData[apiKey];
 | |
|                     console.log('DEBUG assessments_count:', count, 'type:', typeof count);
 | |
|                     console.log('DEBUG full resultsData:', resultsData);
 | |
|                     
 | |
|                     // Vérification de sécurité
 | |
|                     const safeCount = (count !== undefined && count !== null && !isNaN(count)) ? count : 0;
 | |
|                     value = `${safeCount} évaluation${safeCount > 1 ? 's' : ''}`;
 | |
|                     
 | |
|                     // Pour le texte, on met à jour directement sans animation
 | |
|                     element.textContent = value;
 | |
|                 } else {
 | |
|                     // Format numérique pour les autres statistiques
 | |
|                     value = typeof resultsData[apiKey] === 'number' 
 | |
|                         ? resultsData[apiKey].toFixed(1) 
 | |
|                         : resultsData[apiKey];
 | |
|                     this.animateNumber(element, value);
 | |
|                 }
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         // Mise à jour de l'histogramme si présent
 | |
|         if (resultsData.distribution) {
 | |
|             this.updateHistogram(resultsData.distribution);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Animation des nombres
 | |
|      */
 | |
|     animateNumber(element, targetValue, duration = 1000) {
 | |
|         const startValue = parseFloat(element.textContent) || 0;
 | |
|         const isInteger = Number.isInteger(targetValue);
 | |
|         
 | |
|         if (startValue === targetValue) return;
 | |
| 
 | |
|         const startTime = Date.now();
 | |
|         
 | |
|         const animate = () => {
 | |
|             const elapsed = Date.now() - startTime;
 | |
|             const progress = Math.min(elapsed / duration, 1);
 | |
|             
 | |
|             // Easing function
 | |
|             const easeOut = 1 - Math.pow(1 - progress, 3);
 | |
|             
 | |
|             const currentValue = startValue + (targetValue - startValue) * easeOut;
 | |
|             const displayValue = isInteger ? Math.round(currentValue) : currentValue.toFixed(1);
 | |
|             
 | |
|             element.textContent = displayValue;
 | |
|             
 | |
|             if (progress < 1) {
 | |
|                 requestAnimationFrame(animate);
 | |
|             }
 | |
|         };
 | |
|         
 | |
|         requestAnimationFrame(animate);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'histogramme
 | |
|      */
 | |
|     updateHistogram(distribution) {
 | |
|         const histogramContainer = document.querySelector('[data-histogram]');
 | |
|         if (!histogramContainer) return;
 | |
| 
 | |
|         const maxValue = Math.max(...distribution);
 | |
|         const bars = distribution.map((value, index) => {
 | |
|             const height = maxValue > 0 ? (value / maxValue) * 100 : 0;
 | |
|             return `
 | |
|                 <div class="flex flex-col items-center">
 | |
|                     <div class="bg-blue-200 border border-blue-300 rounded-t" 
 | |
|                          style="height: ${height}px; width: 20px; min-height: 2px;"></div>
 | |
|                     <span class="text-xs mt-1">${index}</span>
 | |
|                     <span class="text-xs text-gray-600">${value}</span>
 | |
|                 </div>
 | |
|             `;
 | |
|         }).join('');
 | |
| 
 | |
|         histogramContainer.innerHTML = `
 | |
|             <div class="flex items-end justify-center space-x-1 h-32">
 | |
|                 ${bars}
 | |
|             </div>
 | |
|         `;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'interface des onglets trimestre
 | |
|      */
 | |
|     updateTrimesterTabsUI() {
 | |
|         this.elements.trimesterTabs.forEach(tab => {
 | |
|             const tabTrimester = tab.dataset.trimesterTab;
 | |
|             const tabValue = tabTrimester === 'global' ? null : parseInt(tabTrimester);
 | |
|             
 | |
|             if (tabValue === this.state.currentTrimester) {
 | |
|                 tab.classList.add('active', 'bg-blue-600', 'text-white');
 | |
|                 tab.classList.remove('bg-white', 'text-gray-700');
 | |
|                 tab.setAttribute('aria-selected', 'true');
 | |
|             } else {
 | |
|                 tab.classList.remove('active', 'bg-blue-600', 'text-white');
 | |
|                 tab.classList.add('bg-white', 'text-gray-700');
 | |
|                 tab.setAttribute('aria-selected', 'false');
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'interface des cards expandées
 | |
|      */
 | |
|     updateExpandedCardsUI() {
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             const cardType = card.dataset.statsCard;
 | |
|             const isExpanded = this.state.expandedCards.has(cardType);
 | |
|             
 | |
|             const content = card.querySelector('[data-card-content]');
 | |
|             const icon = card.querySelector('[data-expand-icon]');
 | |
|             
 | |
|             if (content) {
 | |
|                 content.style.display = isExpanded ? 'block' : 'none';
 | |
|             }
 | |
|             
 | |
|             if (icon) {
 | |
|                 icon.style.transform = isExpanded ? 'rotate(180deg)' : 'rotate(0deg)';
 | |
|             }
 | |
|             
 | |
|             this.updateCardAccessibility(cardType, isExpanded);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'accessibilité d'une card
 | |
|      */
 | |
|     updateCardAccessibility(cardType, isExpanded) {
 | |
|         const card = document.querySelector(`[data-stats-card="${cardType}"]`);
 | |
|         if (!card) return;
 | |
| 
 | |
|         const header = card.querySelector('[data-card-header]');
 | |
|         const content = card.querySelector('[data-card-content]');
 | |
| 
 | |
|         if (header) {
 | |
|             header.setAttribute('aria-expanded', isExpanded.toString());
 | |
|         }
 | |
| 
 | |
|         if (content) {
 | |
|             content.setAttribute('aria-hidden', (!isExpanded).toString());
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion de la navigation clavier
 | |
|      */
 | |
|     handleKeyboardNavigation(event) {
 | |
|         if (!this.state.isInitialized) return;
 | |
| 
 | |
|         // Navigation dans les onglets avec les flèches
 | |
|         if (event.target.matches('[data-trimester-tab]')) {
 | |
|             let currentIndex = Array.from(this.elements.trimesterTabs).indexOf(event.target);
 | |
|             
 | |
|             switch (event.key) {
 | |
|                 case 'ArrowLeft':
 | |
|                     event.preventDefault();
 | |
|                     currentIndex = currentIndex > 0 ? currentIndex - 1 : this.elements.trimesterTabs.length - 1;
 | |
|                     this.elements.trimesterTabs[currentIndex].focus();
 | |
|                     break;
 | |
|                     
 | |
|                 case 'ArrowRight':
 | |
|                     event.preventDefault();
 | |
|                     currentIndex = currentIndex < this.elements.trimesterTabs.length - 1 ? currentIndex + 1 : 0;
 | |
|                     this.elements.trimesterTabs[currentIndex].focus();
 | |
|                     break;
 | |
|                     
 | |
|                 case 'Enter':
 | |
|                 case ' ':
 | |
|                     event.preventDefault();
 | |
|                     event.target.click();
 | |
|                     break;
 | |
|             }
 | |
|         }
 | |
| 
 | |
|         // Toggle des cards avec Entrée/Espace
 | |
|         if (event.target.matches('[data-card-header]') && (event.key === 'Enter' || event.key === ' ')) {
 | |
|             event.preventDefault();
 | |
|             event.target.click();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Configuration des gestes tactiles avancés pour mobile
 | |
|      */
 | |
|     setupAdvancedTouchGestures() {
 | |
|         if (!this.state.currentDevice.isMobile) return;
 | |
|         
 | |
|         const container = this.elements.container;
 | |
|         let isMultiTouch = false;
 | |
|         let initialDistance = 0;
 | |
|         let longPressTimer = null;
 | |
| 
 | |
|         container.addEventListener('touchstart', (event) => {
 | |
|             this.state.touchStartX = event.touches[0].clientX;
 | |
|             this.state.touchStartY = event.touches[0].clientY;
 | |
|             this.state.touchStartTime = Date.now();
 | |
|             this.state.swipeDirection = null;
 | |
|             
 | |
|             // Détection multi-touch
 | |
|             isMultiTouch = event.touches.length > 1;
 | |
|             if (isMultiTouch && event.touches.length === 2) {
 | |
|                 initialDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
 | |
|             }
 | |
|             
 | |
|             // Long press detection
 | |
|             longPressTimer = setTimeout(() => {
 | |
|                 this.handleLongPress(event.touches[0]);
 | |
|                 this.addRippleEffect(event.target, event.touches[0]);
 | |
|             }, 500);
 | |
|             
 | |
|         }, { passive: true });
 | |
| 
 | |
|         container.addEventListener('touchmove', (event) => {
 | |
|             if (!this.state.touchStartX) return;
 | |
|             
 | |
|             // Clear long press timer on move
 | |
|             if (longPressTimer) {
 | |
|                 clearTimeout(longPressTimer);
 | |
|                 longPressTimer = null;
 | |
|             }
 | |
|             
 | |
|             const touchCurrentX = event.touches[0].clientX;
 | |
|             const touchCurrentY = event.touches[0].clientY;
 | |
|             const diffX = touchCurrentX - this.state.touchStartX;
 | |
|             const diffY = touchCurrentY - this.state.touchStartY;
 | |
|             
 | |
|             // Détection de la direction du swipe
 | |
|             if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 10) {
 | |
|                 this.state.swipeDirection = diffX > 0 ? 'right' : 'left';
 | |
|                 this.showSwipeIndicator(this.state.swipeDirection);
 | |
|             }
 | |
|             
 | |
|             // Pinch to expand detection
 | |
|             if (isMultiTouch && event.touches.length === 2) {
 | |
|                 const currentDistance = this.getTouchDistance(event.touches[0], event.touches[1]);
 | |
|                 const scale = currentDistance / initialDistance;
 | |
|                 
 | |
|                 if (scale > 1.2) {
 | |
|                     this.handlePinchExpand(event.target);
 | |
|                 }
 | |
|             }
 | |
|             
 | |
|             // Prevent horizontal scroll if swiping
 | |
|             if (Math.abs(diffX) > 30) {
 | |
|                 event.preventDefault();
 | |
|             }
 | |
|         }, { passive: false });
 | |
| 
 | |
|         container.addEventListener('touchend', (event) => {
 | |
|             // Clear timers
 | |
|             if (longPressTimer) {
 | |
|                 clearTimeout(longPressTimer);
 | |
|                 longPressTimer = null;
 | |
|             }
 | |
|             
 | |
|             this.hideSwipeIndicators();
 | |
|             
 | |
|             if (!this.state.touchStartX) return;
 | |
| 
 | |
|             const touchEndX = event.changedTouches[0].clientX;
 | |
|             const touchEndTime = Date.now();
 | |
|             const diffX = this.state.touchStartX - touchEndX;
 | |
|             const swipeTime = touchEndTime - this.state.touchStartTime;
 | |
|             const minSwipeDistance = 50;
 | |
|             const maxSwipeTime = 300;
 | |
| 
 | |
|             // Swipe validation
 | |
|             if (Math.abs(diffX) > minSwipeDistance && swipeTime < maxSwipeTime) {
 | |
|                 const currentTrimesterIndex = this.getCurrentTrimesterIndex();
 | |
|                 const velocity = Math.abs(diffX) / swipeTime;
 | |
|                 
 | |
|                 // Add haptic feedback if available
 | |
|                 this.triggerHapticFeedback('light');
 | |
|                 
 | |
|                 if (diffX > 0 && currentTrimesterIndex < this.elements.trimesterTabs.length - 1) {
 | |
|                     // Swipe vers la gauche - trimestre suivant
 | |
|                     this.animatedTrimesterChange(currentTrimesterIndex + 1, 'right');
 | |
|                 } else if (diffX < 0 && currentTrimesterIndex > 0) {
 | |
|                     // Swipe vers la droite - trimestre précédent
 | |
|                     this.animatedTrimesterChange(currentTrimesterIndex - 1, 'left');
 | |
|                 }
 | |
|             }
 | |
| 
 | |
|             this.resetTouchState();
 | |
|         }, { passive: true });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Obtient l'index du trimestre actuellement sélectionné
 | |
|      */
 | |
|     getCurrentTrimesterIndex() {
 | |
|         return Array.from(this.elements.trimesterTabs).findIndex(tab => {
 | |
|             const tabTrimester = tab.dataset.trimesterTab;
 | |
|             const tabValue = tabTrimester === 'global' ? null : parseInt(tabTrimester);
 | |
|             return tabValue === this.state.currentTrimester;
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion du redimensionnement de la fenêtre
 | |
|      */
 | |
|     handleResize() {
 | |
|         // Réinitialiser les hauteurs des cards expandées
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             if (this.state.expandedCards.has(card.dataset.statsCard)) {
 | |
|                 const content = card.querySelector('[data-card-content]');
 | |
|                 if (content && content.style.height !== 'auto') {
 | |
|                     content.style.height = 'auto';
 | |
|                 }
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion de l'historique du navigateur
 | |
|      */
 | |
|     handlePopState(event) {
 | |
|         this.restoreStateFromURL();
 | |
|         this.updateTrimesterTabsUI();
 | |
|         this.updateExpandedCardsUI();
 | |
|         
 | |
|         // Recharger les données si nécessaire
 | |
|         if (event.state && event.state.trimester !== this.state.currentTrimester) {
 | |
|             this.fetchStats(this.state.currentTrimester);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Animation de transition entre trimestres
 | |
|      */
 | |
|     async animateTrimesterTransition() {
 | |
|         const content = this.elements.statsContent;
 | |
|         if (!content) return;
 | |
| 
 | |
|         // Animation de sortie
 | |
|         content.style.opacity = '0.6';
 | |
|         content.style.transform = 'translateY(10px)';
 | |
|         content.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
 | |
| 
 | |
|         await new Promise(resolve => setTimeout(resolve, this.options.animationDuration / 2));
 | |
| 
 | |
|         // Animation d'entrée
 | |
|         content.style.opacity = '1';
 | |
|         content.style.transform = 'translateY(0)';
 | |
| 
 | |
|         setTimeout(() => {
 | |
|             content.style.transition = '';
 | |
|         }, this.options.animationDuration);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Animation lors de la mise à jour des statistiques
 | |
|      */
 | |
|     animateStatsUpdate() {
 | |
|         this.elements.statsCards.forEach((card, index) => {
 | |
|             card.style.opacity = '0';
 | |
|             card.style.transform = 'translateY(20px)';
 | |
|             
 | |
|             setTimeout(() => {
 | |
|                 card.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
 | |
|                 card.style.opacity = '1';
 | |
|                 card.style.transform = 'translateY(0)';
 | |
|                 
 | |
|                 setTimeout(() => {
 | |
|                     card.style.transition = '';
 | |
|                 }, this.options.animationDuration);
 | |
|             }, index * 100);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Animation du chargement initial
 | |
|      */
 | |
|     animateInitialLoad() {
 | |
|         const elements = [
 | |
|             ...this.elements.trimesterTabs,
 | |
|             ...this.elements.statsCards
 | |
|         ];
 | |
| 
 | |
|         elements.forEach((element, index) => {
 | |
|             element.style.opacity = '0';
 | |
|             element.style.transform = 'translateY(30px)';
 | |
|             
 | |
|             setTimeout(() => {
 | |
|                 element.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
 | |
|                 element.style.opacity = '1';
 | |
|                 element.style.transform = 'translateY(0)';
 | |
|                 
 | |
|                 setTimeout(() => {
 | |
|                     element.style.transition = '';
 | |
|                 }, this.options.animationDuration);
 | |
|             }, index * 50);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mise à jour de l'URL avec l'état actuel
 | |
|      */
 | |
|     updateURL() {
 | |
|         const params = new URLSearchParams(window.location.search);
 | |
|         
 | |
|         // Trimestre
 | |
|         if (this.state.currentTrimester) {
 | |
|             params.set('trimestre', this.state.currentTrimester.toString());
 | |
|         } else {
 | |
|             params.delete('trimestre');
 | |
|         }
 | |
| 
 | |
|         // Cards expandées
 | |
|         if (this.state.expandedCards.size > 0) {
 | |
|             params.set('expanded', Array.from(this.state.expandedCards).join(','));
 | |
|         } else {
 | |
|             params.delete('expanded');
 | |
|         }
 | |
| 
 | |
|         const url = new URL(window.location);
 | |
|         url.search = params.toString();
 | |
|         
 | |
|         // Mise à jour de l'historique sans rechargement
 | |
|         window.history.replaceState(
 | |
|             { trimester: this.state.currentTrimester }, 
 | |
|             document.title, 
 | |
|             url.toString()
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Affichage de l'état de chargement
 | |
|      */
 | |
|     showLoading() {
 | |
|         this.state.loading = true;
 | |
|         
 | |
|         if (this.elements.loadingOverlay) {
 | |
|             this.elements.loadingOverlay.classList.remove('hidden');
 | |
|         }
 | |
|         
 | |
|         // Désactiver les contrôles
 | |
|         this.elements.trimesterTabs.forEach(tab => {
 | |
|             tab.style.pointerEvents = 'none';
 | |
|             tab.style.opacity = '0.6';
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Masquage de l'état de chargement
 | |
|      */
 | |
|     hideLoading() {
 | |
|         this.state.loading = false;
 | |
|         
 | |
|         if (this.elements.loadingOverlay) {
 | |
|             this.elements.loadingOverlay.classList.add('hidden');
 | |
|         }
 | |
|         
 | |
|         // Réactiver les contrôles
 | |
|         this.elements.trimesterTabs.forEach(tab => {
 | |
|             tab.style.pointerEvents = '';
 | |
|             tab.style.opacity = '';
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Affichage d'une erreur
 | |
|      */
 | |
|     showError(message) {
 | |
|         if (this.elements.errorContainer) {
 | |
|             this.elements.errorContainer.innerHTML = `
 | |
|                 <div class="bg-red-50 border border-red-200 rounded-lg p-4">
 | |
|                     <div class="flex">
 | |
|                         <div class="flex-shrink-0">
 | |
|                             <svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
 | |
|                                 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
 | |
|                             </svg>
 | |
|                         </div>
 | |
|                         <div class="ml-3">
 | |
|                             <p class="text-sm font-medium text-red-800">${message}</p>
 | |
|                         </div>
 | |
|                     </div>
 | |
|                 </div>
 | |
|             `;
 | |
|             this.elements.errorContainer.classList.remove('hidden');
 | |
|         }
 | |
|         
 | |
|         Notytex.utils.showToast(message, 'error');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Génération du HTML squelette pour le chargement
 | |
|      */
 | |
|     createSkeletonHTML(cardType) {
 | |
|         const skeletonItems = cardType === 'results' ? 5 : 3;
 | |
|         
 | |
|         return Array(skeletonItems).fill().map(() => `
 | |
|             <div class="flex items-center justify-between py-2 animate-pulse">
 | |
|                 <div class="flex items-center space-x-3">
 | |
|                     <div class="w-4 h-4 bg-gray-300 rounded-full"></div>
 | |
|                     <div class="h-4 bg-gray-300 rounded w-24"></div>
 | |
|                 </div>
 | |
|                 <div class="text-right">
 | |
|                     <div class="h-4 bg-gray-300 rounded w-16 mb-1"></div>
 | |
|                     <div class="h-3 bg-gray-200 rounded w-20"></div>
 | |
|                 </div>
 | |
|             </div>
 | |
|         `).join('');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Rendu du contenu détaillé d'une card
 | |
|      */
 | |
|     renderDetailedContent(cardType, data, container) {
 | |
|         switch (cardType) {
 | |
|             case 'domains':
 | |
|                 this.renderDomainsDetail(data, container);
 | |
|                 break;
 | |
|             case 'competences':
 | |
|                 this.renderCompetencesDetail(data, container);
 | |
|                 break;
 | |
|             case 'results':
 | |
|                 this.renderResultsDetail(data, container);
 | |
|                 break;
 | |
|             default:
 | |
|                 container.innerHTML = '<p class="text-gray-600 text-sm">Contenu détaillé non disponible</p>';
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Rendu des détails des domaines
 | |
|      */
 | |
|     renderDomainsDetail(data, container) {
 | |
|         if (!data.detailed_breakdown) {
 | |
|             container.innerHTML = '<p class="text-gray-600 text-sm">Détails non disponibles</p>';
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const html = data.detailed_breakdown.map(domain => `
 | |
|             <div class="border border-gray-200 rounded-lg p-3 mb-3">
 | |
|                 <div class="flex items-center justify-between mb-2">
 | |
|                     <h4 class="font-semibold text-gray-900">${domain.name}</h4>
 | |
|                     <span class="text-sm text-gray-600">${domain.total_points.toFixed(1)} pts</span>
 | |
|                 </div>
 | |
|                 <div class="space-y-1">
 | |
|                     ${domain.assessments.map(assessment => `
 | |
|                         <div class="flex justify-between text-sm">
 | |
|                             <span class="text-gray-700">${assessment.name}</span>
 | |
|                             <span class="font-medium">${assessment.points.toFixed(1)} pts</span>
 | |
|                         </div>
 | |
|                     `).join('')}
 | |
|                 </div>
 | |
|             </div>
 | |
|         `).join('');
 | |
| 
 | |
|         container.innerHTML = html;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Rendu des détails des compétences
 | |
|      */
 | |
|     renderCompetencesDetail(data, container) {
 | |
|         if (!data.detailed_breakdown) {
 | |
|             container.innerHTML = '<p class="text-gray-600 text-sm">Détails non disponibles</p>';
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const html = data.detailed_breakdown.map(competence => `
 | |
|             <div class="border border-gray-200 rounded-lg p-3 mb-3">
 | |
|                 <div class="flex items-center justify-between mb-2">
 | |
|                     <h4 class="font-semibold text-gray-900">${competence.name}</h4>
 | |
|                     <span class="text-sm text-gray-600">${competence.total_points.toFixed(1)} pts</span>
 | |
|                 </div>
 | |
|                 <div class="space-y-1">
 | |
|                     ${competence.assessments.map(assessment => `
 | |
|                         <div class="flex justify-between text-sm">
 | |
|                             <span class="text-gray-700">${assessment.name}</span>
 | |
|                             <span class="font-medium">${assessment.points.toFixed(1)} pts</span>
 | |
|                         </div>
 | |
|                     `).join('')}
 | |
|                 </div>
 | |
|             </div>
 | |
|         `).join('');
 | |
| 
 | |
|         container.innerHTML = html;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Rendu des détails des résultats
 | |
|      */
 | |
|     renderResultsDetail(data, container) {
 | |
|         if (!data.student_rankings) {
 | |
|             container.innerHTML = '<p class="text-gray-600 text-sm">Classement non disponible</p>';
 | |
|             return;
 | |
|         }
 | |
| 
 | |
|         const html = `
 | |
|             <div class="space-y-2">
 | |
|                 <h4 class="font-semibold text-gray-900 mb-3">Top 10 des étudiants</h4>
 | |
|                 ${data.student_rankings.slice(0, 10).map((student, index) => `
 | |
|                     <div class="flex items-center justify-between py-2 ${index < 3 ? 'bg-yellow-50 rounded px-2' : ''}">
 | |
|                         <div class="flex items-center space-x-3">
 | |
|                             <span class="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium">
 | |
|                                 ${index + 1}
 | |
|                             </span>
 | |
|                             <span class="text-gray-900">${student.name}</span>
 | |
|                         </div>
 | |
|                         <span class="font-semibold text-gray-900">${student.average.toFixed(1)}/20</span>
 | |
|                     </div>
 | |
|                 `).join('')}
 | |
|             </div>
 | |
|         `;
 | |
| 
 | |
|         container.innerHTML = html;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Vérification de la validité du cache
 | |
|      */
 | |
|     isCacheValid(key) {
 | |
|         const cached = this.state.cache.get(key);
 | |
|         if (!cached) return false;
 | |
|         
 | |
|         const age = Date.now() - cached.timestamp;
 | |
|         return age < this.options.cacheTimeout;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Détection avancée du type d'appareil
 | |
|      */
 | |
|     detectDevice() {
 | |
|         const width = window.innerWidth;
 | |
|         const height = window.innerHeight;
 | |
|         const isTouchDevice = 'ontouchstart' in window;
 | |
|         const hasHover = window.matchMedia('(hover: hover)').matches;
 | |
|         const connection = navigator.connection;
 | |
|         
 | |
|         return {
 | |
|             isMobile: width < 768,
 | |
|             isTablet: width >= 768 && width < 1024,
 | |
|             isDesktop: width >= 1024,
 | |
|             isTouchDevice,
 | |
|             hasHover,
 | |
|             pixelRatio: window.devicePixelRatio || 1,
 | |
|             orientation: height > width ? 'portrait' : 'landscape',
 | |
|             isSlowNetwork: connection ? connection.effectiveType === 'slow-2g' || connection.effectiveType === '2g' : false,
 | |
|             reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches
 | |
|         };
 | |
|     }
 | |
|     
 | |
|     /**
 | |
|      * Détection d'un appareil mobile (legacy)
 | |
|      */
 | |
|     isMobile() {
 | |
|         return this.state.currentDevice.isMobile;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Configuration du comportement responsive
 | |
|      */
 | |
|     setupResponsiveBehavior() {
 | |
|         // Écouter les changements d'orientation
 | |
|         window.addEventListener('orientationchange', () => {
 | |
|             setTimeout(() => {
 | |
|                 this.state.currentDevice = this.detectDevice();
 | |
|                 this.adaptLayoutToDevice();
 | |
|             }, 100);
 | |
|         });
 | |
| 
 | |
|         // Écouter les changements de taille d'écran
 | |
|         const resizeObserver = new ResizeObserver((entries) => {
 | |
|             this.handleResponsiveResize(entries);
 | |
|         });
 | |
| 
 | |
|         if (this.elements.container) {
 | |
|             resizeObserver.observe(this.elements.container);
 | |
|         }
 | |
| 
 | |
|         // Adapter le layout initial
 | |
|         this.adaptLayoutToDevice();
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Adaptation du layout selon le device
 | |
|      */
 | |
|     adaptLayoutToDevice() {
 | |
|         const { isMobile, isTablet, isSlowNetwork, reducedMotion } = this.state.currentDevice;
 | |
|         
 | |
|         if (isMobile) {
 | |
|             this.enableMobileOptimizations();
 | |
|         } else if (isTablet) {
 | |
|             this.enableTabletOptimizations();
 | |
|         } else {
 | |
|             this.enableDesktopOptimizations();
 | |
|         }
 | |
| 
 | |
|         if (isSlowNetwork) {
 | |
|             this.enableLowBandwidthMode();
 | |
|         }
 | |
| 
 | |
|         if (reducedMotion) {
 | |
|             this.disableAnimations();
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Optimisations mobile
 | |
|      */
 | |
|     enableMobileOptimizations() {
 | |
|         // Sticky navigation
 | |
|         if (this.elements.container) {
 | |
|             this.elements.container.classList.add('mobile-layout');
 | |
|         }
 | |
| 
 | |
|         // Accordéon behavior pour les cards
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             card.classList.add('mobile-accordion');
 | |
|         });
 | |
| 
 | |
|         // Touch optimizations
 | |
|         document.body.style.touchAction = 'manipulation';
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Optimisations tablette
 | |
|      */
 | |
|     enableTabletOptimizations() {
 | |
|         if (this.elements.container) {
 | |
|             this.elements.container.classList.add('tablet-layout');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Optimisations desktop
 | |
|      */
 | |
|     enableDesktopOptimizations() {
 | |
|         if (this.elements.container) {
 | |
|             this.elements.container.classList.add('desktop-layout');
 | |
|         }
 | |
| 
 | |
|         // Enable hover effects
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             card.classList.add('hover-enabled');
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Mode faible bande passante
 | |
|      */
 | |
|     enableLowBandwidthMode() {
 | |
|         // Réduire la qualité des animations
 | |
|         this.options.animationDuration = this.options.animationDuration * 0.5;
 | |
|         
 | |
|         // Lazy loading plus agressif
 | |
|         this.options.cacheTimeout = this.options.cacheTimeout * 2;
 | |
|         
 | |
|         if (this.elements.container) {
 | |
|             this.elements.container.classList.add('low-bandwidth');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Désactiver les animations
 | |
|      */
 | |
|     disableAnimations() {
 | |
|         this.options.animationDuration = 0;
 | |
|         
 | |
|         if (this.elements.container) {
 | |
|             this.elements.container.classList.add('no-animations');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Configuration de l'IntersectionObserver pour le lazy loading
 | |
|      */
 | |
|     setupIntersectionObserver() {
 | |
|         if (!('IntersectionObserver' in window)) return;
 | |
| 
 | |
|         this.state.intersectionObserver = new IntersectionObserver((entries) => {
 | |
|             entries.forEach(entry => {
 | |
|                 if (entry.isIntersecting) {
 | |
|                     this.lazyLoadCard(entry.target);
 | |
|                     this.state.intersectionObserver.unobserve(entry.target);
 | |
|                 }
 | |
|             });
 | |
|         }, {
 | |
|             rootMargin: '50px',
 | |
|             threshold: 0.1
 | |
|         });
 | |
| 
 | |
|         // Observer les cards non visibles
 | |
|         this.elements.statsCards.forEach(card => {
 | |
|             if (!this.isElementInViewport(card)) {
 | |
|                 this.state.intersectionObserver.observe(card);
 | |
|             }
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Lazy loading d'une card
 | |
|      */
 | |
|     lazyLoadCard(cardElement) {
 | |
|         const cardType = cardElement.dataset.statsCard;
 | |
|         if (cardType && this.state.expandedCards.has(cardType)) {
 | |
|             this.loadCardDetailedContent(cardType);
 | |
|         }
 | |
| 
 | |
|         // Animation d'apparition
 | |
|         cardElement.style.opacity = '0';
 | |
|         cardElement.style.transform = 'translateY(30px)';
 | |
|         
 | |
|         requestAnimationFrame(() => {
 | |
|             cardElement.style.transition = `opacity ${this.options.animationDuration}ms ease-out, transform ${this.options.animationDuration}ms ease-out`;
 | |
|             cardElement.style.opacity = '1';
 | |
|             cardElement.style.transform = 'translateY(0)';
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Configuration du monitoring de performance
 | |
|      */
 | |
|     setupPerformanceMonitoring() {
 | |
|         if (!('PerformanceObserver' in window)) return;
 | |
| 
 | |
|         // Cumulative Layout Shift (CLS)
 | |
|         const clsObserver = new PerformanceObserver((list) => {
 | |
|             for (const entry of list.getEntries()) {
 | |
|                 if (!entry.hadRecentInput) {
 | |
|                     this.state.performanceMetrics.cls += entry.value;
 | |
|                 }
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         try {
 | |
|             clsObserver.observe({ type: 'layout-shift', buffered: true });
 | |
|         } catch (e) {
 | |
|             // Fallback pour les anciens navigateurs
 | |
|         }
 | |
| 
 | |
|         // Largest Contentful Paint (LCP)
 | |
|         const lcpObserver = new PerformanceObserver((list) => {
 | |
|             const entries = list.getEntries();
 | |
|             const lastEntry = entries[entries.length - 1];
 | |
|             this.state.performanceMetrics.lcp = lastEntry.startTime;
 | |
|         });
 | |
| 
 | |
|         try {
 | |
|             lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
 | |
|         } catch (e) {
 | |
|             // Fallback pour les anciens navigateurs
 | |
|         }
 | |
| 
 | |
|         // First Input Delay (FID)
 | |
|         const fidObserver = new PerformanceObserver((list) => {
 | |
|             for (const entry of list.getEntries()) {
 | |
|                 this.state.performanceMetrics.fid = entry.processingStart - entry.startTime;
 | |
|             }
 | |
|         });
 | |
| 
 | |
|         try {
 | |
|             fidObserver.observe({ type: 'first-input', buffered: true });
 | |
|         } catch (e) {
 | |
|             // Fallback pour les anciens navigateurs
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Préchargement intelligent des données
 | |
|      */
 | |
|     setupSmartPrefetching() {
 | |
|         // Prédire le prochain trimestre basé sur l'usage
 | |
|         const predictedTrimester = this.predictNextTrimester();
 | |
|         
 | |
|         if (predictedTrimester && !this.state.currentDevice.isSlowNetwork) {
 | |
|             setTimeout(() => {
 | |
|                 this.prefetchTrimesterData(predictedTrimester);
 | |
|             }, 2000);
 | |
|         }
 | |
| 
 | |
|         // Précharger les détails des cards visibles
 | |
|         const visibleCards = Array.from(this.elements.statsCards).filter(card => {
 | |
|             return this.isElementInViewport(card) && !this.state.expandedCards.has(card.dataset.statsCard);
 | |
|         });
 | |
| 
 | |
|         visibleCards.forEach((card, index) => {
 | |
|             setTimeout(() => {
 | |
|                 this.prefetchCardDetails(card.dataset.statsCard);
 | |
|             }, index * 500);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Prédiction du prochain trimestre
 | |
|      */
 | |
|     predictNextTrimester() {
 | |
|         const current = this.state.currentTrimester;
 | |
|         if (!current) return 1; // Si global, prédire trimestre 1
 | |
|         if (current < 3) return current + 1;
 | |
|         return null; // Pas de prédiction pour le trimestre 3
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Préchargement des données d'un trimestre
 | |
|      */
 | |
|     async prefetchTrimesterData(trimester) {
 | |
|         const cacheKey = `stats-${this.classId}-${trimester}`;
 | |
|         
 | |
|         if (!this.isCacheValid(cacheKey)) {
 | |
|             try {
 | |
|                 const url = `/classes/${this.classId}/stats?trimestre=${trimester}`;
 | |
|                 const response = await fetch(url, {
 | |
|                     method: 'GET',
 | |
|                     headers: {
 | |
|                         'Accept': 'application/json',
 | |
|                         'X-Requested-With': 'XMLHttpRequest'
 | |
|                     }
 | |
|                 });
 | |
| 
 | |
|                 if (response.ok) {
 | |
|                     const data = await response.json();
 | |
|                     this.state.cache.set(cacheKey, {
 | |
|                         data: data,
 | |
|                         timestamp: Date.now()
 | |
|                     });
 | |
|                 }
 | |
|             } catch (error) {
 | |
|                 console.log('Erreur lors du préchargement:', error);
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Préchargement des détails d'une card
 | |
|      */
 | |
|     async prefetchCardDetails(cardType) {
 | |
|         const trimester = this.state.currentTrimester;
 | |
|         const url = trimester 
 | |
|             ? `/classes/${this.classId}/stats/${cardType}?trimestre=${trimester}`
 | |
|             : `/classes/${this.classId}/stats/${cardType}`;
 | |
| 
 | |
|         try {
 | |
|             await fetch(url, {
 | |
|                 method: 'GET',
 | |
|                 headers: {
 | |
|                     'Accept': 'application/json',
 | |
|                     'X-Requested-With': 'XMLHttpRequest'
 | |
|                 }
 | |
|             });
 | |
|         } catch (error) {
 | |
|             console.log('Erreur lors du préchargement des détails:', error);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion du redimensionnement responsive
 | |
|      */
 | |
|     handleResponsiveResize(entries) {
 | |
|         for (const entry of entries) {
 | |
|             const newDevice = this.detectDevice();
 | |
|             
 | |
|             // Si changement de catégorie d'appareil
 | |
|             if (newDevice.isMobile !== this.state.currentDevice.isMobile ||
 | |
|                 newDevice.isTablet !== this.state.currentDevice.isTablet) {
 | |
|                 
 | |
|                 this.state.currentDevice = newDevice;
 | |
|                 this.adaptLayoutToDevice();
 | |
|             }
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Distance entre deux points tactiles
 | |
|      */
 | |
|     getTouchDistance(touch1, touch2) {
 | |
|         const dx = touch1.clientX - touch2.clientX;
 | |
|         const dy = touch1.clientY - touch2.clientY;
 | |
|         return Math.sqrt(dx * dx + dy * dy);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion du long press
 | |
|      */
 | |
|     handleLongPress(touch) {
 | |
|         const element = document.elementFromPoint(touch.clientX, touch.clientY);
 | |
|         const card = element?.closest('[data-stats-card]');
 | |
|         
 | |
|         if (card) {
 | |
|             // Afficher menu contextuel ou actions rapides
 | |
|             this.showContextMenu(card, touch.clientX, touch.clientY);
 | |
|             this.triggerHapticFeedback('medium');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Gestion du pinch to expand
 | |
|      */
 | |
|     handlePinchExpand(target) {
 | |
|         const card = target.closest('[data-stats-card]');
 | |
|         if (card && !this.state.expandedCards.has(card.dataset.statsCard)) {
 | |
|             this.toggleCard(card.dataset.statsCard);
 | |
|             this.triggerHapticFeedback('heavy');
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Affichage des indicateurs de swipe
 | |
|      */
 | |
|     showSwipeIndicator(direction) {
 | |
|         const existing = this.elements.container.querySelector('.swipe-indicator');
 | |
|         if (existing) existing.remove();
 | |
| 
 | |
|         const indicator = document.createElement('div');
 | |
|         indicator.className = `swipe-indicator ${direction} visible`;
 | |
|         this.elements.container.appendChild(indicator);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Masquer les indicateurs de swipe
 | |
|      */
 | |
|     hideSwipeIndicators() {
 | |
|         const indicators = this.elements.container.querySelectorAll('.swipe-indicator');
 | |
|         indicators.forEach(indicator => {
 | |
|             indicator.classList.remove('visible');
 | |
|             setTimeout(() => indicator.remove(), 300);
 | |
|         });
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Changement de trimestre avec animation directionnelle
 | |
|      */
 | |
|     async animatedTrimesterChange(newIndex, direction) {
 | |
|         const newTab = this.elements.trimesterTabs[newIndex];
 | |
|         if (!newTab) return;
 | |
| 
 | |
|         // Animation de sortie
 | |
|         this.elements.statsContent.style.transform = `translateX(${direction === 'right' ? '-20px' : '20px'})`;
 | |
|         this.elements.statsContent.style.opacity = '0.6';
 | |
| 
 | |
|         // Changer le trimestre
 | |
|         await newTab.click();
 | |
| 
 | |
|         // Animation d'entrée
 | |
|         setTimeout(() => {
 | |
|             this.elements.statsContent.style.transform = 'translateX(0)';
 | |
|             this.elements.statsContent.style.opacity = '1';
 | |
|         }, 150);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Effet ripple sur touch
 | |
|      */
 | |
|     addRippleEffect(element, touch) {
 | |
|         const ripple = document.createElement('span');
 | |
|         const rect = element.getBoundingClientRect();
 | |
|         const size = Math.max(rect.width, rect.height);
 | |
|         
 | |
|         ripple.style.width = ripple.style.height = size + 'px';
 | |
|         ripple.style.left = (touch.clientX - rect.left - size / 2) + 'px';
 | |
|         ripple.style.top = (touch.clientY - rect.top - size / 2) + 'px';
 | |
|         ripple.className = 'ripple';
 | |
|         
 | |
|         element.style.position = 'relative';
 | |
|         element.style.overflow = 'hidden';
 | |
|         element.appendChild(ripple);
 | |
|         
 | |
|         setTimeout(() => ripple.remove(), 600);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Feedback haptique
 | |
|      */
 | |
|     triggerHapticFeedback(intensity = 'light') {
 | |
|         if ('vibrate' in navigator) {
 | |
|             const patterns = {
 | |
|                 light: [10],
 | |
|                 medium: [20],
 | |
|                 heavy: [30, 10, 30]
 | |
|             };
 | |
|             navigator.vibrate(patterns[intensity] || patterns.light);
 | |
|         }
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Menu contextuel
 | |
|      */
 | |
|     showContextMenu(card, x, y) {
 | |
|         const existing = document.querySelector('.context-menu');
 | |
|         if (existing) existing.remove();
 | |
| 
 | |
|         const menu = document.createElement('div');
 | |
|         menu.className = 'context-menu';
 | |
|         menu.style.cssText = `
 | |
|             position: fixed;
 | |
|             top: ${y}px;
 | |
|             left: ${x}px;
 | |
|             background: white;
 | |
|             border: 1px solid #e5e7eb;
 | |
|             border-radius: 8px;
 | |
|             box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
 | |
|             z-index: 1000;
 | |
|             padding: 8px 0;
 | |
|         `;
 | |
| 
 | |
|         const actions = [
 | |
|             { label: 'Développer', action: () => this.expandCard(card.dataset.statsCard) },
 | |
|             { label: 'Actualiser', action: () => this.refreshCardData(card.dataset.statsCard) }
 | |
|         ];
 | |
| 
 | |
|         actions.forEach(({ label, action }) => {
 | |
|             const item = document.createElement('button');
 | |
|             item.textContent = label;
 | |
|             item.className = 'block w-full text-left px-4 py-2 hover:bg-gray-50';
 | |
|             item.onclick = () => {
 | |
|                 action();
 | |
|                 menu.remove();
 | |
|             };
 | |
|             menu.appendChild(item);
 | |
|         });
 | |
| 
 | |
|         document.body.appendChild(menu);
 | |
|         setTimeout(() => menu.remove(), 3000);
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Reset de l'état tactile
 | |
|      */
 | |
|     resetTouchState() {
 | |
|         this.state.touchStartX = null;
 | |
|         this.state.touchStartY = null;
 | |
|         this.state.touchStartTime = null;
 | |
|         this.state.swipeDirection = null;
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Vérification si un élément est dans le viewport
 | |
|      */
 | |
|     isElementInViewport(element) {
 | |
|         const rect = element.getBoundingClientRect();
 | |
|         return (
 | |
|             rect.top >= 0 &&
 | |
|             rect.left >= 0 &&
 | |
|             rect.bottom <= window.innerHeight &&
 | |
|             rect.right <= window.innerWidth
 | |
|         );
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Actualisation des données d'une card
 | |
|      */
 | |
|     async refreshCardData(cardType) {
 | |
|         const trimester = this.state.currentTrimester;
 | |
|         const cacheKey = `stats-${this.classId}-${trimester || 'global'}`;
 | |
|         
 | |
|         // Supprimer du cache
 | |
|         this.state.cache.delete(cacheKey);
 | |
|         
 | |
|         // Recharger
 | |
|         await this.fetchStats(trimester);
 | |
|         
 | |
|         Notytex.utils.showToast('Données actualisées', 'success');
 | |
|     }
 | |
| 
 | |
|     /**
 | |
|      * Nettoyage des ressources
 | |
|      */
 | |
|     destroy() {
 | |
|         // Supprimer les écouteurs d'événements
 | |
|         window.removeEventListener('resize', this.handleResize);
 | |
|         window.removeEventListener('popstate', this.handlePopState);
 | |
|         document.removeEventListener('keydown', this.handleKeyboardNavigation);
 | |
|         
 | |
|         // Nettoyer les observers
 | |
|         if (this.state.intersectionObserver) {
 | |
|             this.state.intersectionObserver.disconnect();
 | |
|         }
 | |
|         
 | |
|         // Vider le cache
 | |
|         this.state.cache.clear();
 | |
|         
 | |
|         // Nettoyer les éléments temporaires
 | |
|         document.querySelectorAll('.context-menu, .swipe-indicator, .ripple').forEach(el => el.remove());
 | |
|         
 | |
|         // Réinitialiser l'état
 | |
|         this.state.isInitialized = false;
 | |
|     }
 | |
| }
 | |
| 
 | |
| // Export pour utilisation globale
 | |
| window.ClassDashboard = ClassDashboard;
 | |
| 
 | |
| // Auto-initialisation si les éléments sont présents
 | |
| document.addEventListener('DOMContentLoaded', () => {
 | |
|     const dashboardContainer = document.querySelector('[data-class-dashboard]');
 | |
|     if (dashboardContainer) {
 | |
|         const classId = dashboardContainer.dataset.classId;
 | |
|         if (classId) {
 | |
|             window.currentClassDashboard = new ClassDashboard(classId);
 | |
|         }
 | |
|     }
 | |
| }); |