/** * 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 = {}; // Charts instances this.studentAveragesChart = null; 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 = '
Détails supplémentaires bientôt disponibles
'; detailContainer.dataset.loaded = 'true'; } catch (error) { console.error(`Erreur lors du chargement des détails ${cardType}:`, error); detailContainer.innerHTML = '

Erreur lors du chargement

'; } } /** * 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 = '
Aucun domaine évalué
'; return; } container.innerHTML = domainsData.map(domain => `
${domain.name}
${domain.total_points}pts
${domain.elements_count} ${domain.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
`).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 = '
Aucune compétence évaluée
'; return; } container.innerHTML = competencesData.map(competence => `
${competence.name}
${competence.total_points}pts
${competence.elements_count} ${competence.elements_count > 1 ? 'éléments évalués' : 'élément évalué'}
`).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 des moyennes des élèves if (resultsData.student_averages_distribution) { this.updateStudentAveragesChart(resultsData.student_averages_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 des moyennes des élèves avec Chart.js */ updateStudentAveragesChart(distribution) { const canvas = document.getElementById('studentAveragesChart'); const noDataElement = document.querySelector('[data-chart-no-data]'); if (!canvas) return; // Vérifier s'il y a des données const hasData = distribution && distribution.length > 0 && distribution.some(item => item.count > 0); if (!hasData) { if (noDataElement) { noDataElement.style.display = 'flex'; } // Détruire le graphique existant if (this.studentAveragesChart) { this.studentAveragesChart.destroy(); this.studentAveragesChart = null; } return; } if (noDataElement) { noDataElement.style.display = 'none'; } // Détruire le graphique existant if (this.studentAveragesChart) { this.studentAveragesChart.destroy(); } // Préparer les données const labels = distribution.map(item => item.range); const data = distribution.map(item => item.count); const maxCount = Math.max(...data); // Créer le graphique this.studentAveragesChart = new Chart(canvas, { type: 'bar', data: { labels: labels, datasets: [{ label: 'Nombre d\'élèves', data: data, backgroundColor: 'rgba(251, 146, 60, 0.8)', borderColor: 'rgba(251, 146, 60, 1)', borderWidth: 1, borderRadius: 4, borderSkipped: false }] }, options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false }, tooltip: { backgroundColor: 'rgba(0, 0, 0, 0.8)', titleColor: 'white', bodyColor: 'white', borderColor: 'rgba(251, 146, 60, 1)', borderWidth: 1, callbacks: { title: function(tooltipItems) { return `Moyenne: ${tooltipItems[0].label}`; }, label: function(context) { const count = context.parsed.y; return `${count} élève${count > 1 ? 's' : ''}`; } } } }, scales: { x: { display: true, grid: { display: false }, ticks: { color: 'rgba(251, 146, 60, 0.8)', font: { size: 10 }, maxRotation: 0 } }, y: { display: true, beginAtZero: true, max: maxCount > 0 ? Math.ceil(maxCount * 1.1) : 1, grid: { color: 'rgba(251, 146, 60, 0.1)' }, ticks: { color: 'rgba(251, 146, 60, 0.8)', font: { size: 10 }, stepSize: 1 } } }, animation: { duration: this.options.animationDuration || 800, easing: 'easeInOutCubic' } } }); } /** * Mise à jour de l'histogramme (legacy - gardé pour compatibilité) */ 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 `
${index} ${value}
`; }).join(''); histogramContainer.innerHTML = `
${bars}
`; } /** * 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 = `

${message}

`; 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(() => `
`).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 = '

Contenu détaillé non disponible

'; } } /** * Rendu des détails des domaines */ renderDomainsDetail(data, container) { if (!data.detailed_breakdown) { container.innerHTML = '

Détails non disponibles

'; return; } const html = data.detailed_breakdown.map(domain => `

${domain.name}

${domain.total_points.toFixed(1)} pts
${domain.assessments.map(assessment => `
${assessment.name} ${assessment.points.toFixed(1)} pts
`).join('')}
`).join(''); container.innerHTML = html; } /** * Rendu des détails des compétences */ renderCompetencesDetail(data, container) { if (!data.detailed_breakdown) { container.innerHTML = '

Détails non disponibles

'; return; } const html = data.detailed_breakdown.map(competence => `

${competence.name}

${competence.total_points.toFixed(1)} pts
${competence.assessments.map(assessment => `
${assessment.name} ${assessment.points.toFixed(1)} pts
`).join('')}
`).join(''); container.innerHTML = html; } /** * Rendu des détails des résultats */ renderResultsDetail(data, container) { if (!data.student_rankings) { container.innerHTML = '

Classement non disponible

'; return; } const html = `

Top 10 des étudiants

${data.student_rankings.slice(0, 10).map((student, index) => `
${index + 1} ${student.name}
${student.average.toFixed(1)}/20
`).join('')}
`; 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(); } // Nettoyer les charts if (this.studentAveragesChart) { this.studentAveragesChart.destroy(); this.studentAveragesChart = null; } // 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); } } });