Files
notytex/static/js/ClassDashboard.js

1848 lines
62 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 = {};
// 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 = '<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 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 `
<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();
}
// 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);
}
}
});