Les établissements scolaires utilisent des systèmes d'évaluation variés (notes /20, /10, lettres, compétences, sans notes). Jusqu'ici l'application imposait implicitement le mode notes /20, ce qui ne correspondait pas à la réalité pédagogique de nombreuses écoles. Cette configuration permet à chaque établissement de choisir son mode de notation par année scolaire, avec verrouillage automatique dès que des notes ont été saisies pour éviter les incohérences. Le Score Sérénité adapte ses pondérations selon le mode choisi (les compétences sont converties via un mapping, le mode sans notes exclut la composante notes).
117 lines
3.3 KiB
TypeScript
117 lines
3.3 KiB
TypeScript
/**
|
|
* Web Vitals monitoring for frontend performance.
|
|
*
|
|
* Captures Core Web Vitals (LCP, FID, CLS) and sends to analytics.
|
|
* These metrics are critical for user experience and SEO.
|
|
*
|
|
* @see Story 1.8 - T8.4: Web Vitals (FCP, LCP, TTI)
|
|
*/
|
|
|
|
import { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from 'web-vitals';
|
|
|
|
type VitalsReporter = (metric: VitalMetric) => void;
|
|
|
|
export interface VitalMetric {
|
|
name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB';
|
|
value: number;
|
|
rating: 'good' | 'needs-improvement' | 'poor';
|
|
delta: number;
|
|
id: string;
|
|
}
|
|
|
|
/**
|
|
* Web Vitals thresholds (Core Web Vitals 2024 standards).
|
|
*
|
|
* - LCP (Largest Contentful Paint): < 2.5s good, < 4s needs improvement
|
|
* - FCP (First Contentful Paint): < 1.8s good, < 3s needs improvement
|
|
* - INP (Interaction to Next Paint): < 200ms good, < 500ms needs improvement
|
|
* - CLS (Cumulative Layout Shift): < 0.1 good, < 0.25 needs improvement
|
|
* - TTFB (Time to First Byte): < 800ms good, < 1.8s needs improvement
|
|
*/
|
|
const THRESHOLDS = {
|
|
LCP: { good: 2500, needsImprovement: 4000 },
|
|
FCP: { good: 1800, needsImprovement: 3000 },
|
|
INP: { good: 200, needsImprovement: 500 },
|
|
CLS: { good: 0.1, needsImprovement: 0.25 },
|
|
TTFB: { good: 800, needsImprovement: 1800 }
|
|
} as const;
|
|
|
|
function getRating(
|
|
name: VitalMetric['name'],
|
|
value: number
|
|
): 'good' | 'needs-improvement' | 'poor' {
|
|
const threshold = THRESHOLDS[name];
|
|
if (value <= threshold.good) return 'good';
|
|
if (value <= threshold.needsImprovement) return 'needs-improvement';
|
|
return 'poor';
|
|
}
|
|
|
|
function createVitalMetric(metric: Metric): VitalMetric {
|
|
return {
|
|
name: metric.name as VitalMetric['name'],
|
|
value: metric.value,
|
|
rating: getRating(metric.name as VitalMetric['name'], metric.value),
|
|
delta: metric.delta,
|
|
id: metric.id
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Initialize Web Vitals collection.
|
|
*
|
|
* @param reporter - Callback function to report metrics (e.g., to analytics)
|
|
*/
|
|
export function initWebVitals(reporter: VitalsReporter): void {
|
|
// Core Web Vitals
|
|
onLCP((metric) => reporter(createVitalMetric(metric)));
|
|
onCLS((metric) => reporter(createVitalMetric(metric)));
|
|
onINP((metric) => reporter(createVitalMetric(metric)));
|
|
|
|
// Other Web Vitals
|
|
onFCP((metric) => reporter(createVitalMetric(metric)));
|
|
onTTFB((metric) => reporter(createVitalMetric(metric)));
|
|
}
|
|
|
|
/**
|
|
* Default reporter that logs to console (dev) or sends to backend (prod).
|
|
*/
|
|
export function createDefaultReporter(options: {
|
|
endpoint?: string;
|
|
debug?: boolean;
|
|
}): VitalsReporter {
|
|
return (metric: VitalMetric) => {
|
|
// Log in development
|
|
if (options.debug) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(`[WebVitals] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
|
}
|
|
|
|
// Send to analytics endpoint in production
|
|
if (options.endpoint) {
|
|
// Use sendBeacon for reliability during page unload
|
|
const body = JSON.stringify({
|
|
metric: metric.name,
|
|
value: metric.value,
|
|
rating: metric.rating,
|
|
delta: metric.delta,
|
|
id: metric.id,
|
|
timestamp: Date.now(),
|
|
url: window.location.href
|
|
});
|
|
|
|
if (navigator.sendBeacon) {
|
|
navigator.sendBeacon(options.endpoint, body);
|
|
} else {
|
|
fetch(options.endpoint, {
|
|
method: 'POST',
|
|
body,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
keepalive: true
|
|
}).catch(() => {
|
|
// Silently fail - vitals are best-effort
|
|
});
|
|
}
|
|
}
|
|
};
|
|
}
|