feat: Calculer automatiquement les moyennes après chaque saisie de notes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Les enseignants ont besoin de moyennes à jour immédiatement après la
publication ou modification des notes, sans attendre un batch nocturne.

Le système recalcule via Domain Events synchrones : statistiques
d'évaluation (min/max/moyenne/médiane), moyennes matières pondérées
(normalisation /20), et moyenne générale par élève. Les résultats sont
stockés dans des tables dénormalisées avec cache Redis (TTL 5 min).

Trois endpoints API exposent les données avec contrôle d'accès par rôle.
Une commande console permet le backfill des données historiques au
déploiement.
This commit is contained in:
2026-03-30 06:22:03 +02:00
parent b70d5ec2ad
commit b7dc27f2a5
786 changed files with 118783 additions and 316 deletions

View File

@@ -0,0 +1,187 @@
<script lang="ts">
interface CompetencyProgress {
competencyId: string;
competencyCode: string;
competencyName: string;
currentLevelCode: string | null;
currentLevelName: string | null;
history: Array<{ date: string; levelCode: string; evaluationTitle: string }>;
}
interface CompetencyLevel {
code: string;
name: string;
color: string | null;
sortOrder: number;
}
interface Props {
progress: CompetencyProgress[];
levels: CompetencyLevel[];
}
let { progress, levels }: Props = $props();
function getBarWidth(levelCode: string | null): number {
if (!levelCode || levels.length === 0) return 0;
const idx = levels.findIndex((l) => l.code === levelCode);
if (idx === -1) return 0;
return ((idx + 1) / levels.length) * 100;
}
function getBarColor(levelCode: string | null): string {
if (!levelCode) return '#e2e8f0';
const level = levels.find((l) => l.code === levelCode);
return level?.color ?? '#94a3b8';
}
</script>
<div class="competency-progress-chart">
{#if progress.length === 0}
<p class="empty-message">Aucune compétence évaluée pour le moment.</p>
{:else}
<div class="bars-container">
{#each progress as item (item.competencyId)}
<div class="bar-row">
<div class="bar-label" title={item.competencyName}>
<span class="bar-code">{item.competencyCode}</span>
<span class="bar-name">{item.competencyName}</span>
</div>
<div class="bar-track">
<div
class="bar-fill"
style="width: {getBarWidth(item.currentLevelCode)}%; background-color: {getBarColor(item.currentLevelCode)};"
title={item.currentLevelName ?? 'Non évalué'}
></div>
<!-- Level markers -->
{#each levels as level, i (level.code)}
<div
class="bar-marker"
style="left: {((i + 1) / levels.length) * 100}%"
title={level.name}
></div>
{/each}
</div>
<div class="bar-value" style="color: {getBarColor(item.currentLevelCode)}">
{item.currentLevelName ?? '-'}
</div>
</div>
{/each}
</div>
<!-- Level scale -->
<div class="level-scale">
{#each levels as level (level.code)}
<span class="scale-item" style="--level-color: {level.color}">
<span class="scale-dot" style="background-color: {level.color}"></span>
{level.name}
</span>
{/each}
</div>
{/if}
</div>
<style>
.competency-progress-chart {
width: 100%;
}
.empty-message {
text-align: center;
color: var(--color-text-secondary, #64748b);
padding: 2rem;
}
.bars-container {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bar-row {
display: grid;
grid-template-columns: 200px 1fr 120px;
align-items: center;
gap: 0.75rem;
}
@media (max-width: 640px) {
.bar-row {
grid-template-columns: 1fr;
gap: 0.25rem;
}
}
.bar-label {
overflow: hidden;
}
.bar-code {
font-weight: 700;
font-size: 0.8rem;
color: var(--color-text, #1e293b);
}
.bar-name {
display: block;
font-size: 0.75rem;
color: var(--color-text-secondary, #64748b);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bar-track {
position: relative;
height: 1.25rem;
background: var(--color-surface-alt, #f1f5f9);
border-radius: 0.625rem;
overflow: hidden;
}
.bar-fill {
height: 100%;
border-radius: 0.625rem;
transition: width 0.3s ease;
}
.bar-marker {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--color-border, #e2e8f0);
}
.bar-value {
font-size: 0.8rem;
font-weight: 600;
text-align: right;
}
.level-scale {
display: flex;
justify-content: center;
gap: 1.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border, #e2e8f0);
flex-wrap: wrap;
}
.scale-item {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8rem;
color: var(--color-text-secondary, #64748b);
}
.scale-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 50%;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,64 @@
const DB_NAME = 'classeo-appreciations';
const DB_VERSION = 1;
const STORE_NAME = 'pending-appreciations';
interface PendingAppreciation {
evaluationId: string;
studentId: string;
appreciation: string | null;
savedAt: number;
}
function openDb(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
const store = db.createObjectStore(STORE_NAME, {
keyPath: ['evaluationId', 'studentId'],
});
store.createIndex('byEvaluation', 'evaluationId', { unique: false });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
export async function savePendingAppreciation(appreciation: PendingAppreciation): Promise<void> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).put(appreciation);
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}
export async function getPendingAppreciations(evaluationId: string): Promise<PendingAppreciation[]> {
const db = await openDb();
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const index = tx.objectStore(STORE_NAME).index('byEvaluation');
const request = index.getAll(evaluationId);
request.onsuccess = () => resolve(request.result as PendingAppreciation[]);
request.onerror = () => reject(request.error);
});
}
export async function clearPendingAppreciations(evaluationId: string): Promise<void> {
const db = await openDb();
const pending = await getPendingAppreciations(evaluationId);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
for (const item of pending) {
store.delete([item.evaluationId, item.studentId]);
}
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
}