feat: Permettre à l'élève de consulter ses notes et moyennes
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

L'élève avait accès à ses compétences mais pas à ses notes numériques.
Cette fonctionnalité lui donne une vue complète de sa progression scolaire
avec moyennes par matière, détail par évaluation, statistiques de classe,
et un mode "découverte" pour révéler ses notes à son rythme (FR14, FR15).

Les notes ne sont visibles qu'après publication par l'enseignant, ce qui
garantit que l'élève les découvre avant ses parents (délai 24h story 6.7).
This commit is contained in:
2026-04-05 16:04:26 +02:00
parent b7dc27f2a5
commit cf76314d0e
23 changed files with 2457 additions and 30 deletions

View File

@@ -107,7 +107,7 @@ test.describe('Admin Search & Pagination (Story 2.8b)', () => {
await page.waitForTimeout(500);
// URL should contain search param
await expect(page).toHaveURL(/[?&]search=test-search/);
await expect(page).toHaveURL(/[?&]search=test-search/, { timeout: 15000 });
});
test('search term from URL is restored on page load', async ({ page }) => {

View File

@@ -229,7 +229,7 @@ test.describe('Branding Visual Customization', () => {
await responsePromise;
// Success message
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.alert-success')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.alert-success')).toContainText(/couleurs mises à jour/i);
// CSS variables applied to document root

View File

@@ -321,8 +321,8 @@ test.describe('Calendar Management (Story 2.11)', () => {
).toBeVisible({ timeout: 10000 });
// Verify specific imported holiday entries are displayed
await expect(page.getByText('Toussaint', { exact: true })).toBeVisible();
await expect(page.getByText('Noël', { exact: true })).toBeVisible();
await expect(page.getByText('Toussaint', { exact: true })).toBeVisible({ timeout: 15000 });
await expect(page.getByText('Noël', { exact: true })).toBeVisible({ timeout: 15000 });
// Verify entry cards exist (not just the heading)
const holidaySection = page.locator('.entry-section').filter({

View File

@@ -237,11 +237,11 @@ test.describe('Competencies Mode (Story 6.5)', () => {
// Click to set level
await levelBtn.click();
await expect(levelBtn).toHaveClass(/active/, { timeout: 5000 });
await expect(levelBtn).toHaveClass(/active/, { timeout: 15000 });
// Click same button immediately to toggle off (no wait for save)
await levelBtn.click();
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 5000 });
await expect(levelBtn).not.toHaveClass(/active/, { timeout: 15000 });
});
});

View File

@@ -106,10 +106,10 @@ test.describe('Dashboard Responsive Navigation', () => {
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
await expect(drawer).toBeVisible({ timeout: 10000 });
const logoutButton = drawer.locator('.mobile-logout');
await expect(logoutButton).toBeVisible();
await expect(logoutButton).toBeVisible({ timeout: 10000 });
await expect(logoutButton).toHaveText(/déconnexion/i);
});
});

View File

@@ -397,12 +397,17 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
timeout: 20000
});
// Switch to week view
// Switch to week view (retry click if view doesn't switch — Svelte hydration race)
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
await expect(weekButton).toBeVisible({ timeout: 10000 });
await weekButton.click();
// Week headers should show
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
const lunHeader = page.getByText('Lun', { exact: true });
try {
await expect(lunHeader).toBeVisible({ timeout: 10000 });
} catch {
await weekButton.click();
await expect(lunHeader).toBeVisible({ timeout: 30000 });
}
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
});
@@ -422,7 +427,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
// Navigate forward and wait for the new day to load
await page.getByLabel('Suivant').click();
// Wait for the day title to change, confirming navigation completed
await page.waitForTimeout(1500);
await page.waitForTimeout(3000);
// Navigate back to the original day
await page.getByLabel('Précédent').click();

View File

@@ -295,7 +295,7 @@ test.describe('Sessions Management', () => {
await page.goto(getTenantUrl('/settings/sessions'));
// Should redirect to login
await expect(page).toHaveURL(/login/, { timeout: 5000 });
await expect(page).toHaveURL(/login/, { timeout: 30000 });
});
});
@@ -332,7 +332,7 @@ test.describe('Sessions Management', () => {
await page.locator('.back-button').click();
// Wait for navigation - URL should no longer contain /sessions
await expect(page).not.toHaveURL(/\/sessions/);
await expect(page).not.toHaveURL(/\/sessions/, { timeout: 15000 });
// Verify we're on the main settings page
await expect(page.getByText(/paramètres|mes sessions/i).first()).toBeVisible();

View File

@@ -116,10 +116,19 @@ test.describe('Settings Page [P1]', () => {
await page.goto(getTenantUrl('/settings'));
// Click on the Sessions card (it's a button with heading text)
await page.getByText(/mes sessions/i).click();
// Wait for the settings page to be fully interactive before clicking
const sessionsCard = page.getByText(/mes sessions/i);
await expect(sessionsCard).toBeVisible({ timeout: 15000 });
await expect(page).toHaveURL(/\/settings\/sessions/);
// Click and retry once if navigation doesn't happen (Svelte hydration race)
await sessionsCard.click();
try {
await page.waitForURL(/\/settings\/sessions/, { timeout: 10000 });
} catch {
// Retry click in case hydration wasn't complete
await sessionsCard.click();
await page.waitForURL(/\/settings\/sessions/, { timeout: 30000 });
}
await expect(
page.getByRole('heading', { name: /mes sessions/i })
).toBeVisible();

View File

@@ -0,0 +1,415 @@
import { test, expect } from '@playwright/test';
import { execWithRetry, runSql, clearCache, resolveDeterministicIds, createTestUser, composeFile } from './helpers';
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const STUDENT_EMAIL = 'e2e-student-grades@example.com';
const STUDENT_PASSWORD = 'StudentGrades123';
const TEACHER_EMAIL = 'e2e-sg-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherGrades123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
let classId: string;
let subjectId: string;
let subject2Id: string;
let studentId: string;
let evalId1: string;
let evalId2: string;
let periodId: string;
function uuid5(name: string): string {
return execWithRetry(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5("6ba7b814-9dad-11d1-80b4-00c04fd430c8","${name}")->toString();` +
`' 2>&1`
).trim();
}
async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 60000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Student Grade Consultation (Story 6.6)', () => {
test.describe.configure({ mode: 'serial' });
test.beforeAll(async () => {
// Create users
createTestUser('ecole-alpha', STUDENT_EMAIL, STUDENT_PASSWORD, 'ROLE_ELEVE --firstName=Émilie --lastName=Dubois');
createTestUser('ecole-alpha', TEACHER_EMAIL, TEACHER_PASSWORD, 'ROLE_PROF');
const { schoolId, academicYearId } = resolveDeterministicIds(TENANT_ID);
// Resolve student ID
const idOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM users WHERE email='${STUDENT_EMAIL}' AND tenant_id='${TENANT_ID}'" 2>&1`
);
const idMatch = idOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
studentId = idMatch![0]!;
// Create deterministic IDs
classId = uuid5(`sg-class-${TENANT_ID}`);
subjectId = uuid5(`sg-subject1-${TENANT_ID}`);
subject2Id = uuid5(`sg-subject2-${TENANT_ID}`);
evalId1 = uuid5(`sg-eval1-${TENANT_ID}`);
evalId2 = uuid5(`sg-eval2-${TENANT_ID}`);
periodId = uuid5(`sg-period-${TENANT_ID}`);
// Create class
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES ('${classId}', '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-SG-4A', '4ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Create subjects
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subjectId}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Mathématiques', 'E2ESGMATH', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` +
`VALUES ('${subject2Id}', '${TENANT_ID}', '${schoolId}', 'E2E-SG-Français', 'E2ESGFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
// Assign student to class
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${classId}', '${academicYearId}', NOW(), NOW(), NOW()) ON CONFLICT (user_id, academic_year_id) DO NOTHING`
);
// Create teacher assignment
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subjectId}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
runSql(
`INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, '${classId}', '${subject2Id}', '${academicYearId}', 'active', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Create published evaluation 1 (Maths - older)
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`SELECT '${evalId1}', '${TENANT_ID}', '${classId}', '${subjectId}', u.id, 'DS Mathématiques', '2026-03-01', 20, 2.0, 'published', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO NOTHING`
);
// Create published evaluation 2 (Français - more recent)
runSql(
`INSERT INTO evaluations (id, tenant_id, class_id, subject_id, teacher_id, title, evaluation_date, grade_scale, coefficient, status, grades_published_at, created_at, updated_at) ` +
`SELECT '${evalId2}', '${TENANT_ID}', '${classId}', '${subject2Id}', u.id, 'Dictée', '2026-03-15', 20, 1.0, 'published', NOW(), NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (id) DO NOTHING`
);
// Insert grades
runSql(
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at, appreciation) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId1}', '${studentId}', 16.5, 'graded', u.id, NOW(), NOW(), 'Très bon travail' ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
);
runSql(
`INSERT INTO grades (id, tenant_id, evaluation_id, student_id, value, status, created_by, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', '${evalId2}', '${studentId}', 14.0, 'graded', u.id, NOW(), NOW() ` +
`FROM users u WHERE u.email='${TEACHER_EMAIL}' AND u.tenant_id='${TENANT_ID}' ` +
`ON CONFLICT (evaluation_id, student_id) DO NOTHING`
);
// Insert class statistics
runSql(
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
`VALUES ('${evalId1}', 14.2, 8.0, 18.5, 14.5, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
);
runSql(
`INSERT INTO evaluation_statistics (evaluation_id, average, min_grade, max_grade, median_grade, graded_count, updated_at) ` +
`VALUES ('${evalId2}', 12.8, 6.0, 17.0, 13.0, 25, NOW()) ON CONFLICT (evaluation_id) DO NOTHING`
);
// Find the academic period covering the current date (needed for /me/averages auto-detection)
const periodOutput = execWithRetry(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM academic_periods WHERE tenant_id='${TENANT_ID}' AND start_date <= CURRENT_DATE AND end_date >= CURRENT_DATE LIMIT 1" 2>&1`
);
const periodMatch = periodOutput.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
periodId = periodMatch ? periodMatch[0]! : uuid5(`sg-period-${TENANT_ID}`);
// Insert student averages (subject + general)
runSql(
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subjectId}', '${periodId}', 16.5, 1, NOW()) ` +
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
);
runSql(
`INSERT INTO student_averages (id, tenant_id, student_id, subject_id, period_id, average, grade_count, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${subject2Id}', '${periodId}', 14.0, 1, NOW()) ` +
`ON CONFLICT (student_id, subject_id, period_id) DO NOTHING`
);
runSql(
`INSERT INTO student_general_averages (id, tenant_id, student_id, period_id, average, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${studentId}', '${periodId}', 15.25, NOW()) ` +
`ON CONFLICT (student_id, period_id) DO NOTHING`
);
clearCache();
});
// =========================================================================
// AC2: Dashboard notes — grades and averages visible
// =========================================================================
test('AC2: student sees recent grades on dashboard', async ({ page }) => {
await loginAsStudent(page);
// Dashboard should show grades widget
const gradesSection = page.locator('.grades-list');
await expect(gradesSection).toBeVisible({ timeout: 15000 });
// Should show at least one grade
const gradeItems = page.locator('.grade-item');
await expect(gradeItems.first()).toBeVisible({ timeout: 10000 });
});
test('AC2: student navigates to full grades page', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
// Page title
await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 15000 });
// Should show grade cards
const gradeCards = page.locator('.grade-card');
await expect(gradeCards.first()).toBeVisible({ timeout: 10000 });
// Should show both grades (Dictée more recent first)
await expect(gradeCards).toHaveCount(2);
});
test('AC2: grades show value, subject, and evaluation title', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// Check first grade (Dictée - more recent)
const firstCard = page.locator('.grade-card').first();
await expect(firstCard.locator('.grade-subject-btn')).toContainText('E2E-SG-Français');
await expect(firstCard.locator('.grade-eval-title')).toContainText('Dictée');
await expect(firstCard.locator('.grade-value')).toContainText('14/20');
// Check second grade (DS Maths)
const secondCard = page.locator('.grade-card').nth(1);
await expect(secondCard.locator('.grade-subject-btn')).toContainText('E2E-SG-Mathématiques');
await expect(secondCard.locator('.grade-value')).toContainText('16.5/20');
});
test('AC2: class statistics visible on grades', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
const firstStats = page.locator('.grade-card').first().locator('.grade-card-stats');
await expect(firstStats).toContainText('Moy. classe');
});
test('AC2: appreciation visible on grade', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// The Maths grade has an appreciation
await expect(page.locator('.grade-appreciation').first()).toContainText('Très bon travail');
});
// =========================================================================
// AC3: Subject detail — click on subject shows all evaluations
// =========================================================================
test('AC3: click on subject shows detail modal', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
// Wait for averages section
const avgCard = page.locator('.average-card').first();
await expect(avgCard).toBeVisible({ timeout: 15000 });
// Click on first subject average card
await avgCard.click();
// Modal should appear
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
// Modal should show grade details
await expect(modal.locator('.detail-item')).toHaveCount(1);
});
test('AC3: subject detail modal closes with Escape', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
const avgCard = page.locator('.average-card').first();
await expect(avgCard).toBeVisible({ timeout: 15000 });
await avgCard.click();
const modal = page.getByRole('dialog');
await expect(modal).toBeVisible({ timeout: 5000 });
await page.keyboard.press('Escape');
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
// =========================================================================
// AC4: Discover mode — notes hidden by default, click to reveal
// =========================================================================
test('AC4: discover mode toggle exists', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.discover-toggle')).toBeVisible({ timeout: 15000 });
await expect(page.locator('.toggle-label')).toContainText('Mode découverte');
});
test('AC4: enabling discover mode hides grade values', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// Enable discover mode
const toggle = page.locator('.discover-toggle input');
await toggle.check();
// Grades should show reveal buttons instead of values
await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 });
await expect(page.locator('.reveal-hint').first()).toContainText('Cliquer pour révéler');
});
test('AC4: clicking reveal shows the grade value', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// Enable discover mode
await page.locator('.discover-toggle input').check();
await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 });
// Click to reveal first grade
await page.locator('.reveal-btn').first().click();
// Grade value should now be visible
await expect(page.locator('.grade-card').first().locator('.grade-value')).toBeVisible({ timeout: 5000 });
});
// =========================================================================
// AC5: Badge "Nouveau" on recent grades
// =========================================================================
test('AC5: new grades show Nouveau badge', async ({ page }) => {
// Clear localStorage to simulate fresh session
await page.goto(`${ALPHA_URL}/login`);
await page.evaluate(() => {
localStorage.removeItem('classeo_grades_seen');
localStorage.removeItem('classeo_grade_preferences');
localStorage.removeItem('classeo_grades_revealed');
});
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// Badges should be visible on new grades
await expect(page.locator('.badge-new').first()).toBeVisible({ timeout: 5000 });
});
// =========================================================================
// AC2: Averages section visible
// =========================================================================
test('AC2: subject averages section displays correctly', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
// Wait for averages section
const avgSection = page.locator('.averages-section');
await expect(avgSection).toBeVisible({ timeout: 15000 });
// Should show heading
await expect(avgSection.getByRole('heading', { name: 'Moyennes par matière' })).toBeVisible();
// Should show at least one average card
const avgCards = page.locator('.average-card');
await expect(avgCards.first()).toBeVisible({ timeout: 10000 });
});
test('AC2: general average visible on grades page', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
const generalAvg = page.locator('.general-average');
await expect(generalAvg).toBeVisible({ timeout: 15000 });
await expect(generalAvg).toContainText('Moyenne générale');
await expect(generalAvg.locator('.avg-value')).toContainText('/20');
});
// =========================================================================
// AC4: Discover mode persistence
// =========================================================================
test('AC4: discover mode persists after page reload', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/student-grades`);
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
// Enable discover mode
await page.locator('.discover-toggle input').check();
await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 });
// Reload the page
await page.reload();
// Discover mode should still be active after reload
await expect(page.locator('.grade-card').first()).toBeVisible({ timeout: 15000 });
await expect(page.locator('.reveal-btn').first()).toBeVisible({ timeout: 5000 });
// Disable discover mode for cleanup
await page.locator('.discover-toggle input').uncheck();
});
// =========================================================================
// Navigation
// =========================================================================
test('student can navigate to grades page from nav bar', async ({ page }) => {
await loginAsStudent(page);
const navLink = page.getByRole('link', { name: /mes notes/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
await navLink.click();
await page.waitForURL(/student-grades/, { timeout: 10000 });
await expect(page.getByRole('heading', { name: 'Mes notes' })).toBeVisible({ timeout: 10000 });
});
});

View File

@@ -350,9 +350,9 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
await navigateToSeededDay(page);
// Wait for day view to load
// Wait for day view to load (may need extra time for navigation on slow CI)
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 15000
timeout: 30000
});
// Switch to week view
@@ -419,7 +419,7 @@ test.describe('Student Schedule Consultation (Story 4.3)', () => {
// Desktop grid should be visible, mobile list should be hidden
const weekList = page.locator('.week-list');
const weekGrid = page.locator('.week-grid');
await expect(weekGrid).toBeVisible({ timeout: 15000 });
await expect(weekGrid).toBeVisible({ timeout: 30000 });
await expect(weekList).not.toBeVisible();
});

View File

@@ -270,8 +270,8 @@ test.describe('Teacher Replacements (Story 2.9)', () => {
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 });
await expect(confirmDialog).not.toBeVisible({ timeout: 15000 });
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 15000 });
});
});

View File

@@ -22,7 +22,7 @@ const config: PlaywrightTestConfig = {
// Use 1 worker in CI to ensure no parallel execution across different browser projects
workers: process.env.CI ? 1 : undefined,
// Long sequential CI runs (~3h) cause sporadic slowdowns across all browsers
expect: process.env.CI ? { timeout: 15000 } : undefined,
expect: process.env.CI ? { timeout: 20000 } : undefined,
use: {
baseURL,
trace: 'on-first-retry',
@@ -45,7 +45,8 @@ const config: PlaywrightTestConfig = {
use: {
browserName: 'firefox'
},
timeout: process.env.CI ? 60000 : undefined
timeout: process.env.CI ? 90000 : undefined,
expect: process.env.CI ? { timeout: 25000 } : undefined
},
{
name: 'webkit',

View File

@@ -2,8 +2,12 @@
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import type { StudentGrade } from '$lib/features/grades/api/studentGrades';
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import type { StudentAverages } from '$lib/features/grades/api/studentGrades';
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
import { isGradeNew, markGradesSeen } from '$lib/features/grades/stores/gradePreferences.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
@@ -36,6 +40,11 @@
let studentHomeworks = $state<StudentHomework[]>([]);
let homeworkLoading = $state(false);
// Grades widget state
let recentGrades = $state<StudentGrade[]>([]);
let studentAverages = $state<StudentAverages | null>(null);
let gradesLoading = $state(false);
let hwStatuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
@@ -88,6 +97,33 @@
}
}
let gradeSeenTimerId: number | null = null;
async function loadGrades() {
gradesLoading = true;
try {
const [all, avgs] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
recentGrades = all.slice(0, 5);
studentAverages = avgs;
const ids = all.map((g) => g.id);
gradeSeenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
} catch {
// Silently fail on dashboard widget
} finally {
gradesLoading = false;
}
}
function gradeColor(value: number | null, scale: number): string {
if (value === null || scale <= 0) return '#6b7280';
const normalized = (value / scale) * 20;
if (normalized >= 14) return '#22c55e';
if (normalized >= 10) return '#f59e0b';
return '#ef4444';
}
// Homework detail modal
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
@@ -116,6 +152,10 @@
if (!isEleve) return;
void loadTodaySchedule();
void loadHomeworks();
void loadGrades();
return () => {
if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId);
};
});
</script>
@@ -179,11 +219,51 @@
<!-- Notes Section -->
<DashboardSection
title="Mes notes"
subtitle={hasRealData ? "Dernières notes" : undefined}
isPlaceholder={!hasRealData}
subtitle={isEleve ? "Dernières notes" : (hasRealData ? "Dernières notes" : undefined)}
isPlaceholder={!isEleve && !hasRealData}
placeholderMessage={isMinor ? "Tes notes apparaîtront ici" : "Vos notes apparaîtront ici"}
>
{#if hasRealData}
{#if isEleve}
{#if gradesLoading}
<SkeletonList items={3} message="Chargement des notes..." />
{:else if recentGrades.length === 0}
<p class="empty-grades">Aucune note publiée</p>
{:else}
{#if studentAverages?.generalAverage != null}
<div class="widget-general-avg">
<span class="widget-avg-label">Moyenne générale</span>
<span class="widget-avg-value" style:color={gradeColor(studentAverages.generalAverage, 20)}>
{studentAverages.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
<ul class="grades-list">
{#each recentGrades as grade}
<li class="grade-item">
<div class="grade-header">
<span class="grade-subject">{grade.subjectName ?? 'Matière'}</span>
{#if isGradeNew(grade.id)}
<span class="grade-badge-new">Nouveau</span>
{/if}
{#if grade.status === 'graded' && grade.value != null}
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
{grade.value}/{grade.gradeScale}
</span>
{:else if grade.status === 'absent'}
<span class="grade-value" style:color="#f59e0b">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-value" style:color="#6b7280">Dispensé</span>
{/if}
</div>
<span class="grade-eval">{grade.evaluationTitle}</span>
</li>
{/each}
</ul>
<a href="/dashboard/student-grades" class="view-all-link">
Voir toutes les notes →
</a>
{/if}
{:else if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des notes..." />
{:else}
@@ -406,6 +486,45 @@
color: #6b7280;
}
.grade-badge-new {
font-size: 0.5625rem;
padding: 0.0625rem 0.375rem;
background: #eff6ff;
color: #2563eb;
border-radius: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.empty-grades {
margin: 0;
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.widget-general-avg {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
margin-bottom: 0.5rem;
}
.widget-avg-label {
font-size: 0.75rem;
color: #6b7280;
}
.widget-avg-value {
font-size: 1rem;
font-weight: 700;
}
/* Homework List */
.homework-list {
list-style: none;

View File

@@ -0,0 +1,82 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface StudentGrade {
id: string;
evaluationId: string;
evaluationTitle: string;
evaluationDate: string;
gradeScale: number;
coefficient: number;
subjectId: string;
subjectName: string | null;
value: number | null;
status: string;
appreciation: string | null;
publishedAt: string | null;
classAverage: number | null;
classMin: number | null;
classMax: number | null;
}
export interface SubjectAverage {
subjectId: string;
subjectName: string | null;
average: number;
gradeCount: number;
}
export interface StudentAverages {
studentId: string;
periodId: string | null;
subjectAverages: SubjectAverage[];
generalAverage: number | null;
}
/**
* Récupère toutes les notes publiées de l'élève connecté.
*/
export async function fetchMyGrades(): Promise<StudentGrade[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/grades`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
}
const json = await response.json();
// API Platform returns hydra:member or raw array
return json['hydra:member'] ?? json.member ?? json;
}
/**
* Récupère les notes de l'élève pour une matière.
*/
export async function fetchMyGradesBySubject(subjectId: string): Promise<StudentGrade[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/me/grades/subject/${encodeURIComponent(subjectId)}`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des notes (${response.status})`);
}
const json = await response.json();
return json['hydra:member'] ?? json.member ?? json;
}
/**
* Récupère les moyennes de l'élève connecté.
*/
export async function fetchMyAverages(periodId?: string): Promise<StudentAverages> {
const apiUrl = getApiBaseUrl();
const params = periodId ? `?periodId=${encodeURIComponent(periodId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/averages${params}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des moyennes (${response.status})`);
}
return response.json();
}

View File

@@ -0,0 +1,105 @@
import { browser } from '$app/environment';
const PREFS_KEY = 'classeo_grade_preferences';
const SEEN_KEY = 'classeo_grades_seen';
const REVEALED_KEY = 'classeo_grades_revealed';
export type RevealMode = 'immediate' | 'discover';
interface GradePreferences {
revealMode: RevealMode;
}
// Reactive state
let revealMode = $state<RevealMode>('immediate');
let seenGradeIds = $state<Set<string>>(new Set());
let revealedGradeIds = $state<Set<string>>(new Set());
// Load from localStorage on init
if (browser) {
try {
const stored = localStorage.getItem(PREFS_KEY);
if (stored) {
const prefs = JSON.parse(stored) as GradePreferences;
revealMode = prefs.revealMode ?? 'immediate';
}
} catch {
// Ignore parse errors
}
try {
const stored = localStorage.getItem(SEEN_KEY);
if (stored) {
seenGradeIds = new Set(JSON.parse(stored) as string[]);
}
} catch {
// Ignore
}
try {
const stored = localStorage.getItem(REVEALED_KEY);
if (stored) {
revealedGradeIds = new Set(JSON.parse(stored) as string[]);
}
} catch {
// Ignore
}
}
function savePrefs(): void {
if (!browser) return;
try {
localStorage.setItem(PREFS_KEY, JSON.stringify({ revealMode }));
} catch {
// QuotaExceededError — preference still active in memory
}
}
function saveSeen(): void {
if (!browser) return;
try {
localStorage.setItem(SEEN_KEY, JSON.stringify([...seenGradeIds]));
} catch {
// QuotaExceededError
}
}
function saveRevealed(): void {
if (!browser) return;
try {
localStorage.setItem(REVEALED_KEY, JSON.stringify([...revealedGradeIds]));
} catch {
// QuotaExceededError
}
}
export function getRevealMode(): RevealMode {
return revealMode;
}
export function setRevealMode(mode: RevealMode): void {
revealMode = mode;
savePrefs();
}
export function isGradeNew(gradeId: string): boolean {
return !seenGradeIds.has(gradeId);
}
export function markGradesSeen(gradeIds: string[]): void {
seenGradeIds = new Set([...seenGradeIds, ...gradeIds]);
saveSeen();
}
export function isGradeRevealed(gradeId: string): boolean {
return revealedGradeIds.has(gradeId);
}
export function revealGrade(gradeId: string): void {
revealedGradeIds = new Set([...revealedGradeIds, gradeId]);
saveRevealed();
}
export function isDiscoverMode(): boolean {
return revealMode === 'discover';
}

View File

@@ -109,6 +109,7 @@
{/if}
{#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
<a href="/dashboard/student-grades" class="nav-link" class:active={pathname === '/dashboard/student-grades'}>Mes notes</a>
<a href="/dashboard/student-competencies" class="nav-link" class:active={pathname === '/dashboard/student-competencies'}>Compétences</a>
{/if}
{#if isParent}
@@ -164,6 +165,9 @@
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
Mon emploi du temps
</a>
<a href="/dashboard/student-grades" class="mobile-nav-link" class:active={pathname === '/dashboard/student-grades'}>
Mes notes
</a>
<a href="/dashboard/student-competencies" class="mobile-nav-link" class:active={pathname === '/dashboard/student-competencies'}>
Compétences
</a>

View File

@@ -0,0 +1,691 @@
<script lang="ts">
import type { StudentGrade, StudentAverages, SubjectAverage } from '$lib/features/grades/api/studentGrades';
import { fetchMyGrades, fetchMyAverages } from '$lib/features/grades/api/studentGrades';
import {
setRevealMode,
isGradeNew,
markGradesSeen,
isGradeRevealed,
revealGrade,
isDiscoverMode
} from '$lib/features/grades/stores/gradePreferences.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let grades: StudentGrade[] = $state([]);
let averages: StudentAverages | null = $state(null);
let isLoading = $state(true);
let error: string | null = $state(null);
let selectedSubjectId: string | null = $state(null);
// Group grades by subject
let subjectGroups = $derived.by(() => {
const map = new Map<string, { subjectName: string; grades: StudentGrade[] }>();
for (const g of grades) {
const key = g.subjectId;
const existing = map.get(key);
if (existing) {
existing.grades.push(g);
} else {
map.set(key, { subjectName: g.subjectName ?? 'Matière inconnue', grades: [g] });
}
}
return map;
});
// Find average for a subject
function subjectAverage(subjectId: string): SubjectAverage | undefined {
return averages?.subjectAverages.find((a) => a.subjectId === subjectId);
}
// Filtered grades for selected subject detail
let detailGrades = $derived.by(() => {
if (!selectedSubjectId) return [];
return grades.filter((g) => g.subjectId === selectedSubjectId);
});
let detailSubjectName = $derived.by(() => {
if (!selectedSubjectId) return '';
return subjectGroups.get(selectedSubjectId)?.subjectName ?? '';
});
let detailAverage = $derived.by(() => {
if (!selectedSubjectId) return undefined;
return subjectAverage(selectedSubjectId);
});
// Color based on grade value (green ≥ 14, orange 10-14, red < 10, on /20 scale)
function gradeColor(value: number | null, scale: number): string {
if (value === null || scale <= 0) return '#6b7280';
const normalized = (value / scale) * 20;
if (normalized >= 14) return '#22c55e';
if (normalized >= 10) return '#f59e0b';
return '#ef4444';
}
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' });
}
function handleReveal(gradeId: string) {
revealGrade(gradeId);
}
function toggleDiscoverMode() {
const newMode = isDiscoverMode() ? 'immediate' : 'discover';
setRevealMode(newMode);
}
let seenTimerId: number | null = null;
$effect(() => {
loadData();
return () => {
if (seenTimerId !== null) window.clearTimeout(seenTimerId);
};
});
async function loadData() {
try {
isLoading = true;
error = null;
const [gradesData, averagesData] = await Promise.all([fetchMyGrades(), fetchMyAverages()]);
grades = gradesData;
averages = averagesData;
// Mark all loaded grades as seen (for "Nouveau" badge)
const ids = gradesData.map((g) => g.id);
// Delay marking to let the badge show briefly
seenTimerId = window.setTimeout(() => markGradesSeen(ids), 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function openSubjectDetail(subjectId: string) {
selectedSubjectId = subjectId;
}
function closeDetail() {
selectedSubjectId = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeDetail();
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && selectedSubjectId) closeDetail();
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="student-grades">
<header class="page-header">
<div class="header-top">
<h1>Mes notes</h1>
<label class="discover-toggle">
<input type="checkbox" checked={isDiscoverMode()} onchange={toggleDiscoverMode} />
<span class="toggle-label">Mode découverte</span>
</label>
</div>
{#if averages?.generalAverage != null}
<div class="general-average">
<span class="avg-label">Moyenne générale</span>
<span class="avg-value" style:color={gradeColor(averages.generalAverage, 20)}>
{averages.generalAverage.toFixed(1)}/20
</span>
</div>
{/if}
</header>
{#if error}
<div class="error-banner" role="alert">
<p>{error}</p>
<button onclick={() => (error = null)}>Fermer</button>
</div>
{/if}
{#if isLoading}
<SkeletonList items={5} message="Chargement des notes..." />
{:else if grades.length === 0}
<div class="empty-state">
<p>Aucune note publiée pour le moment.</p>
</div>
{:else}
<!-- Subject averages summary -->
{#if averages && averages.subjectAverages.length > 0}
<section class="averages-section">
<h2>Moyennes par matière</h2>
<div class="averages-grid">
{#each averages.subjectAverages as avg}
<button
class="average-card"
onclick={() => openSubjectDetail(avg.subjectId)}
>
<span class="avg-subject">{avg.subjectName ?? 'Matière'}</span>
<span class="avg-score" style:color={gradeColor(avg.average, 20)}>
{avg.average.toFixed(1)}
</span>
<span class="avg-count">{avg.gradeCount} note{avg.gradeCount > 1 ? 's' : ''}</span>
</button>
{/each}
</div>
</section>
{/if}
<!-- Recent grades -->
<section class="recent-section">
<h2>Dernières notes</h2>
<ul class="grades-list">
{#each grades as grade (grade.id)}
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
{@const isNew = isGradeNew(grade.id)}
<li class="grade-card">
<div class="grade-card-header">
<button class="grade-subject-btn" onclick={() => openSubjectDetail(grade.subjectId)}>{grade.subjectName ?? 'Matière'}</button>
<span class="grade-date">{formatDate(grade.evaluationDate)}</span>
{#if isNew}
<span class="badge-new">Nouveau</span>
{/if}
</div>
<div class="grade-card-body">
<span class="grade-eval-title">{grade.evaluationTitle}</span>
{#if discover}
<button class="reveal-btn" onclick={() => handleReveal(grade.id)}>
<span class="reveal-blur">??/{grade.gradeScale}</span>
<span class="reveal-hint">Cliquer pour révéler</span>
</button>
{:else if grade.status === 'graded' && grade.value != null}
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
{grade.value}/{grade.gradeScale}
</span>
{:else if grade.status === 'absent'}
<span class="grade-status absent">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-status dispensed">Dispensé</span>
{/if}
</div>
{#if !discover && grade.classAverage != null}
<div class="grade-card-stats">
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
{#if grade.classMin != null && grade.classMax != null}
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
{/if}
</div>
{/if}
{#if !discover && grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
<div class="grade-card-meta">
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
</div>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<!-- Subject detail modal -->
{#if selectedSubjectId}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="modal-overlay" onclick={handleOverlayClick} role="presentation">
<div class="modal" role="dialog" aria-modal="true" aria-label="Détail matière {detailSubjectName}">
<div class="modal-header">
<h2>{detailSubjectName}</h2>
<button class="modal-close" onclick={closeDetail} aria-label="Fermer">&times;</button>
</div>
{#if detailAverage}
<div class="modal-average">
<span class="avg-label">Moyenne</span>
<span class="avg-value" style:color={gradeColor(detailAverage.average, 20)}>
{detailAverage.average.toFixed(1)}/20
</span>
</div>
{/if}
<ul class="detail-list">
{#each detailGrades as grade (grade.id)}
{@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)}
<li class="detail-item">
<div class="detail-header">
<span class="detail-title">{grade.evaluationTitle}</span>
<span class="detail-date">{formatDate(grade.evaluationDate)}</span>
</div>
<div class="detail-body">
{#if discover}
<button class="reveal-btn" onclick={() => handleReveal(grade.id)}>
<span class="reveal-blur">??/{grade.gradeScale}</span>
<span class="reveal-hint">Cliquer pour révéler</span>
</button>
{:else if grade.status === 'graded' && grade.value != null}
<span class="grade-value" style:color={gradeColor(grade.value, grade.gradeScale)}>
{grade.value}/{grade.gradeScale}
</span>
{:else if grade.status === 'absent'}
<span class="grade-status absent">Absent</span>
{:else if grade.status === 'dispensed'}
<span class="grade-status dispensed">Dispensé</span>
{/if}
<span class="grade-coeff">Coeff. {grade.coefficient}</span>
</div>
{#if !discover && grade.classAverage != null}
<div class="grade-card-stats">
<span>Moy. classe : {grade.classAverage.toFixed(1)}</span>
{#if grade.classMin != null && grade.classMax != null}
<span>Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)}</span>
{/if}
</div>
{/if}
{#if !discover && grade.appreciation}
<p class="grade-appreciation">{grade.appreciation}</p>
{/if}
</li>
{/each}
</ul>
</div>
</div>
{/if}
<style>
.student-grades {
max-width: 64rem;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.page-header {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.header-top {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.discover-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.875rem;
color: #6b7280;
}
.discover-toggle input {
accent-color: #8b5cf6;
}
.toggle-label {
user-select: none;
}
.general-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.75rem;
}
.avg-label {
font-size: 0.875rem;
color: #6b7280;
}
.avg-value {
font-size: 1.5rem;
font-weight: 700;
}
.error-banner {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
}
.error-banner p {
margin: 0;
}
.error-banner button {
background: none;
border: none;
color: #991b1b;
cursor: pointer;
font-weight: 500;
}
.empty-state {
text-align: center;
padding: 3rem 1rem;
color: #6b7280;
}
/* Averages section */
.averages-section h2,
.recent-section h2 {
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
margin: 0 0 0.75rem;
}
.averages-grid {
display: grid;
gap: 0.75rem;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
}
.average-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition:
box-shadow 0.15s,
border-color 0.15s;
font: inherit;
}
.average-card:hover {
border-color: #3b82f6;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15);
}
.avg-subject {
font-size: 0.75rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
}
.avg-score {
font-size: 1.75rem;
font-weight: 700;
}
.avg-count {
font-size: 0.75rem;
color: #9ca3af;
}
/* Grade cards */
.grades-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.grade-card {
padding: 1rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
}
.grade-card-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.grade-subject-btn {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
background: none;
border: none;
cursor: pointer;
padding: 0;
font: inherit;
text-decoration: underline;
text-decoration-color: transparent;
transition: text-decoration-color 0.15s;
}
.grade-subject-btn:hover {
text-decoration-color: #3b82f6;
}
.grade-date {
font-size: 0.75rem;
color: #9ca3af;
margin-left: auto;
}
.badge-new {
font-size: 0.625rem;
padding: 0.125rem 0.5rem;
background: #eff6ff;
color: #2563eb;
border-radius: 1rem;
font-weight: 600;
text-transform: uppercase;
}
.grade-card-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.grade-eval-title {
font-weight: 500;
color: #1f2937;
}
.grade-value {
font-weight: 700;
font-size: 1.125rem;
white-space: nowrap;
}
.grade-status {
font-size: 0.875rem;
font-weight: 500;
}
.grade-status.absent {
color: #f59e0b;
}
.grade-status.dispensed {
color: #6b7280;
}
.grade-card-stats {
display: flex;
gap: 1rem;
font-size: 0.75rem;
color: #9ca3af;
margin-top: 0.5rem;
}
.grade-appreciation {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #4b5563;
font-style: italic;
}
.grade-card-meta {
margin-top: 0.5rem;
}
.grade-coeff {
font-size: 0.75rem;
color: #9ca3af;
}
/* Reveal button */
.reveal-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
background: #f3f4f6;
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background 0.15s;
}
.reveal-btn:hover {
background: #e5e7eb;
}
.reveal-blur {
font-weight: 700;
font-size: 1.125rem;
color: #9ca3af;
filter: blur(4px);
}
.reveal-hint {
font-size: 0.625rem;
color: #6b7280;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: #1f2937;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.modal-close:hover {
color: #1f2937;
}
.modal-average {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.detail-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.detail-title {
font-weight: 500;
color: #1f2937;
}
.detail-date {
font-size: 0.75rem;
color: #9ca3af;
}
.detail-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
</style>