From cf76314d0edfa3666097f4e4c6d0cbfaf4a3a85f Mon Sep 17 00:00:00 2001 From: Mathias STRASSER Date: Sun, 5 Apr 2026 16:04:26 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Permettre=20=C3=A0=20l'=C3=A9l=C3=A8ve?= =?UTF-8?q?=20de=20consulter=20ses=20notes=20et=20moyennes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../sprint-status.yaml | 4 +- .../StudentGradeCollectionProvider.php | 160 ++++ .../Provider/StudentMyAveragesProvider.php | 94 +++ .../Api/Resource/StudentGradeResource.php | 60 ++ .../Resource/StudentMyAveragesResource.php | 33 + .../Api/StudentGradeEndpointsTest.php | 649 ++++++++++++++++ frontend/e2e/admin-search-pagination.spec.ts | 2 +- frontend/e2e/branding.spec.ts | 2 +- frontend/e2e/calendar.spec.ts | 4 +- frontend/e2e/competencies.spec.ts | 4 +- frontend/e2e/dashboard-responsive-nav.spec.ts | 4 +- frontend/e2e/parent-schedule.spec.ts | 15 +- frontend/e2e/sessions.spec.ts | 4 +- frontend/e2e/settings.spec.ts | 15 +- frontend/e2e/student-grades.spec.ts | 415 +++++++++++ frontend/e2e/student-schedule.spec.ts | 6 +- frontend/e2e/teacher-replacements.spec.ts | 4 +- frontend/playwright.config.ts | 5 +- .../Dashboard/DashboardStudent.svelte | 125 +++- .../lib/features/grades/api/studentGrades.ts | 82 +++ .../grades/stores/gradePreferences.svelte.ts | 105 +++ frontend/src/routes/dashboard/+layout.svelte | 4 + .../dashboard/student-grades/+page.svelte | 691 ++++++++++++++++++ 23 files changed, 2457 insertions(+), 30 deletions(-) create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php create mode 100644 backend/src/Scolarite/Infrastructure/Api/Resource/StudentMyAveragesResource.php create mode 100644 backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php create mode 100644 frontend/e2e/student-grades.spec.ts create mode 100644 frontend/src/lib/features/grades/api/studentGrades.ts create mode 100644 frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts create mode 100644 frontend/src/routes/dashboard/student-grades/+page.svelte diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index 2cd9474..c80f90a 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -115,8 +115,8 @@ development_status: 6-2-saisie-notes-grille-inline: done 6-3-calcul-automatique-des-moyennes: done 6-4-saisie-des-appreciations: done - 6-5-mode-competences: review - 6-6-consultation-notes-par-leleve: ready-for-dev + 6-5-mode-competences: done + 6-6-consultation-notes-par-leleve: review 6-7-consultation-notes-par-le-parent: ready-for-dev 6-8-statistiques-enseignant: ready-for-dev epic-6-retrospective: optional diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php new file mode 100644 index 0000000..9f7cb2f --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentGradeCollectionProvider.php @@ -0,0 +1,160 @@ + + */ +final readonly class StudentGradeCollectionProvider implements ProviderInterface +{ + public function __construct( + private Connection $connection, + private TenantContext $tenantContext, + private Security $security, + ) { + } + + /** @return list */ + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): array + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) { + throw new AccessDeniedHttpException('Accès réservé aux élèves.'); + } + + $tenantId = (string) $this->tenantContext->getCurrentTenantId(); + $studentId = $user->userId(); + + /** @var string|null $subjectId */ + $subjectId = $uriVariables['subjectId'] ?? null; + + if (is_string($subjectId) && $subjectId === '') { + $subjectId = null; + } + + $subjectFilter = $subjectId !== null ? 'AND s.id = :subject_id' : ''; + + $rows = $this->connection->fetchAllAssociative( + "SELECT g.id AS grade_id, g.value, g.status AS grade_status, g.appreciation, + e.id AS evaluation_id, e.title AS evaluation_title, + e.evaluation_date, e.grade_scale, e.coefficient, + e.grades_published_at, + s.id AS subject_id, s.name AS subject_name, + es.average AS class_average, es.min_grade AS class_min, es.max_grade AS class_max + FROM grades g + JOIN evaluations e ON g.evaluation_id = e.id + JOIN subjects s ON e.subject_id = s.id + LEFT JOIN evaluation_statistics es ON es.evaluation_id = e.id + WHERE g.student_id = :student_id + AND g.tenant_id = :tenant_id + AND e.grades_published_at IS NOT NULL + AND e.status != :deleted_status + {$subjectFilter} + ORDER BY e.evaluation_date DESC, e.created_at DESC", + $subjectId !== null + ? ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted', 'subject_id' => $subjectId] + : ['student_id' => $studentId, 'tenant_id' => $tenantId, 'deleted_status' => 'deleted'], + ); + + return array_map(self::hydrateResource(...), $rows); + } + + /** @param array $row */ + private static function hydrateResource(array $row): StudentGradeResource + { + $resource = new StudentGradeResource(); + + /** @var string $gradeId */ + $gradeId = $row['grade_id']; + $resource->id = $gradeId; + + /** @var string $evaluationId */ + $evaluationId = $row['evaluation_id']; + $resource->evaluationId = $evaluationId; + + /** @var string $evaluationTitle */ + $evaluationTitle = $row['evaluation_title']; + $resource->evaluationTitle = $evaluationTitle; + + /** @var string $evaluationDate */ + $evaluationDate = $row['evaluation_date']; + $resource->evaluationDate = $evaluationDate; + + /** @var string|int $gradeScale */ + $gradeScale = $row['grade_scale']; + $resource->gradeScale = (int) $gradeScale; + + /** @var string|float $coefficient */ + $coefficient = $row['coefficient']; + $resource->coefficient = (float) $coefficient; + + /** @var string $subjectIdVal */ + $subjectIdVal = $row['subject_id']; + $resource->subjectId = $subjectIdVal; + + /** @var string|null $subjectName */ + $subjectName = $row['subject_name']; + $resource->subjectName = $subjectName; + + /** @var string|float|null $value */ + $value = $row['value']; + $resource->value = $value !== null ? (float) $value : null; + + /** @var string $gradeStatus */ + $gradeStatus = $row['grade_status']; + $resource->status = $gradeStatus; + + /** @var string|null $appreciation */ + $appreciation = $row['appreciation']; + $resource->appreciation = $appreciation; + + /** @var string|null $publishedAt */ + $publishedAt = $row['grades_published_at']; + $resource->publishedAt = $publishedAt; + + /** @var string|float|null $classAverage */ + $classAverage = $row['class_average']; + $resource->classAverage = $classAverage !== null ? (float) $classAverage : null; + + /** @var string|float|null $classMin */ + $classMin = $row['class_min']; + $resource->classMin = $classMin !== null ? (float) $classMin : null; + + /** @var string|float|null $classMax */ + $classMax = $row['class_max']; + $resource->classMax = $classMax !== null ? (float) $classMax : null; + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php new file mode 100644 index 0000000..1c021f9 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Provider/StudentMyAveragesProvider.php @@ -0,0 +1,94 @@ + + */ +final readonly class StudentMyAveragesProvider implements ProviderInterface +{ + public function __construct( + private StudentAverageRepository $studentAverageRepository, + private PeriodFinder $periodFinder, + private TenantContext $tenantContext, + private Security $security, + ) { + } + + #[Override] + public function provide(Operation $operation, array $uriVariables = [], array $context = []): StudentMyAveragesResource + { + if (!$this->tenantContext->hasTenant()) { + throw new UnauthorizedHttpException('Bearer', 'Tenant non défini.'); + } + + $user = $this->security->getUser(); + + if (!$user instanceof SecurityUser) { + throw new UnauthorizedHttpException('Bearer', 'Authentification requise.'); + } + + if (!in_array(Role::ELEVE->value, $user->getRoles(), true)) { + throw new AccessDeniedHttpException('Accès réservé aux élèves.'); + } + + $tenantId = $this->tenantContext->getCurrentTenantId(); + $studentId = UserId::fromString($user->userId()); + + /** @var array $filters */ + $filters = $context['filters'] ?? []; + /** @var string|null $periodId */ + $periodId = $filters['periodId'] ?? null; + + // Auto-detect current period if not specified + if ($periodId === null) { + $periodInfo = $this->periodFinder->findForDate(new DateTimeImmutable(), $tenantId); + + if ($periodInfo !== null) { + $periodId = $periodInfo->periodId; + } + } + + $resource = new StudentMyAveragesResource(); + $resource->studentId = $user->userId(); + $resource->periodId = $periodId; + + if ($periodId === null) { + return $resource; + } + + $resource->subjectAverages = $this->studentAverageRepository->findDetailedSubjectAveragesForStudent( + $studentId, + $periodId, + $tenantId, + ); + + $resource->generalAverage = $this->studentAverageRepository->findGeneralAverageForStudent( + $studentId, + $periodId, + $tenantId, + ); + + return $resource; + } +} diff --git a/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php new file mode 100644 index 0000000..b4228b7 --- /dev/null +++ b/backend/src/Scolarite/Infrastructure/Api/Resource/StudentGradeResource.php @@ -0,0 +1,60 @@ + */ + public array $subjectAverages = []; + + public ?float $generalAverage = null; +} diff --git a/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php new file mode 100644 index 0000000..ebbdbd8 --- /dev/null +++ b/backend/tests/Functional/Scolarite/Api/StudentGradeEndpointsTest.php @@ -0,0 +1,649 @@ +seedFixtures(); + } + + protected function tearDown(): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement('DELETE FROM evaluation_statistics WHERE evaluation_id IN (SELECT id FROM evaluations WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM student_general_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM student_averages WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM grade_events WHERE grade_id IN (SELECT id FROM grades WHERE tenant_id = :tid)', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM grades WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + $connection->executeStatement('DELETE FROM evaluations WHERE tenant_id = :tid', ['tid' => self::TENANT_ID]); + + parent::tearDown(); + } + + // ========================================================================= + // GET /me/grades — Auth & Access + // ========================================================================= + + #[Test] + public function getMyGradesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getMyGradesReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + #[Test] + public function getMyGradesReturns403ForParent(): void + { + $parentId = '88888888-8888-8888-8888-888888888888'; + $client = $this->createAuthenticatedClient($parentId, ['ROLE_PARENT']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // GET /me/grades — Happy path + // ========================================================================= + + #[Test] + public function getMyGradesReturnsPublishedGradesForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Only published grades should be returned (not unpublished) + self::assertCount(2, $data); + + // First grade (sorted by eval date DESC, subject2 is more recent) + self::assertSame(self::SUBJECT2_ID, $data[0]['subjectId']); + self::assertSame(14.0, $data[0]['value']); + self::assertSame('graded', $data[0]['status']); + self::assertNotNull($data[0]['publishedAt']); + + // Second grade + self::assertSame(self::SUBJECT_ID, $data[1]['subjectId']); + self::assertSame(16.0, $data[1]['value']); + } + + #[Test] + public function getMyGradesDoesNotReturnUnpublishedGrades(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // The unpublished evaluation grade should not appear + foreach ($data as $grade) { + self::assertNotSame((string) $this->unpublishedEvalId, $grade['evaluationId']); + } + } + + #[Test] + public function getMyGradesIncludesClassStatistics(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // First grade should have class statistics + self::assertArrayHasKey('classAverage', $data[0]); + self::assertArrayHasKey('classMin', $data[0]); + self::assertArrayHasKey('classMax', $data[0]); + } + + #[Test] + public function getMyGradesReturnsEmptyForStudentWithNoGrades(): void + { + $noGradeStudentId = '77777777-7777-7777-7777-777777777777'; + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'no-grade@test.local', '', 'No', 'Grades', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => $noGradeStudentId, 'tid' => self::TENANT_ID], + ); + + $client = $this->createAuthenticatedClient($noGradeStudentId, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + self::assertCount(0, $data); + } + + // ========================================================================= + // GET /me/grades/subject/{subjectId} — Happy path + // ========================================================================= + + #[Test] + public function getMyGradesBySubjectFiltersCorrectly(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades/subject/' . self::SUBJECT_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertCount(1, $data); + self::assertSame(self::SUBJECT_ID, $data[0]['subjectId']); + self::assertSame(16.0, $data[0]['value']); + } + + #[Test] + public function getMyGradesBySubjectReturnsEmptyForUnknownSubject(): void + { + $unknownSubjectId = '99999999-9999-9999-9999-999999999999'; + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades/subject/' . $unknownSubjectId, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + self::assertCount(0, $data); + } + + // ========================================================================= + // GET /me/averages — Auth & Access + // ========================================================================= + + #[Test] + public function getMyAveragesReturns401WithoutAuthentication(): void + { + $client = static::createClient(); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(401); + } + + #[Test] + public function getMyAveragesReturns403ForTeacher(): void + { + $client = $this->createAuthenticatedClient(self::TEACHER_ID, ['ROLE_PROF']); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseStatusCodeSame(403); + } + + // ========================================================================= + // GET /me/averages — Happy path + // ========================================================================= + + #[Test] + public function getMyAveragesReturnsAveragesForStudent(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + self::assertJsonContains([ + 'studentId' => self::STUDENT_ID, + 'periodId' => self::PERIOD_ID, + 'generalAverage' => 16.0, + ]); + } + + #[Test] + public function getMyAveragesReturnsSubjectAverages(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages?periodId=' . self::PERIOD_ID, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array{subjectAverages: list>, generalAverage: float|null} $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertNotEmpty($data['subjectAverages']); + self::assertSame(self::SUBJECT_ID, $data['subjectAverages'][0]['subjectId']); + self::assertSame(16.0, $data['subjectAverages'][0]['average']); + } + + // ========================================================================= + // GET /me/grades — Student isolation + // ========================================================================= + + #[Test] + public function getMyGradesReturnsOnlyCurrentStudentGrades(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT2_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Student2 only has 1 grade (eval1, Maths), not the eval2/eval3 grades + self::assertCount(1, $data); + self::assertSame(12.0, $data[0]['value']); + } + + // ========================================================================= + // GET /me/grades — Response completeness + // ========================================================================= + + #[Test] + public function getMyGradesReturnsAllExpectedFields(): void + { + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // First grade (eval2 — Français, more recent) + $grade = $data[0]; + self::assertArrayHasKey('id', $grade); + self::assertArrayHasKey('evaluationId', $grade); + self::assertArrayHasKey('evaluationTitle', $grade); + self::assertArrayHasKey('evaluationDate', $grade); + self::assertArrayHasKey('gradeScale', $grade); + self::assertArrayHasKey('coefficient', $grade); + self::assertArrayHasKey('subjectId', $grade); + self::assertArrayHasKey('value', $grade); + self::assertArrayHasKey('status', $grade); + self::assertArrayHasKey('publishedAt', $grade); + + self::assertSame('Dictée', $grade['evaluationTitle']); + self::assertSame(20, $grade['gradeScale']); + self::assertSame(2.0, $grade['coefficient']); + self::assertSame('Français', $grade['subjectName'] ?? null); + } + + #[Test] + public function getMyGradesIncludesAppreciationWhenSet(): void + { + // Add appreciation to eval1 grade + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $connection->executeStatement( + "UPDATE grades SET appreciation = 'Excellent travail' WHERE student_id = :sid AND evaluation_id IN (SELECT id FROM evaluations WHERE title = 'DS Mathématiques' AND tenant_id = :tid)", + ['sid' => self::STUDENT_ID, 'tid' => self::TENANT_ID], + ); + + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/grades', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var list> $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + // Find the Maths grade (eval1) + $mathsGrade = null; + foreach ($data as $grade) { + if (($grade['evaluationTitle'] ?? null) === 'DS Mathématiques') { + $mathsGrade = $grade; + break; + } + } + + self::assertNotNull($mathsGrade, 'DS Mathématiques grade not found'); + self::assertSame('Excellent travail', $mathsGrade['appreciation']); + } + + // ========================================================================= + // GET /me/averages — Auto-detect period + // ========================================================================= + + #[Test] + public function getMyAveragesReturnsEmptyWhenNoPeriodCoversCurrentDate(): void + { + // The seeded period (2026-01-01 to 2026-03-31) does not cover today (2026-04-04) + // So auto-detect returns no period → empty averages + $client = $this->createAuthenticatedClient(self::STUDENT_ID, ['ROLE_ELEVE']); + $client->request('GET', self::BASE_URL . '/me/averages', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + self::assertResponseIsSuccessful(); + + /** @var string $content */ + $content = $client->getResponse()->getContent(); + /** @var array $data */ + $data = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + + self::assertArrayHasKey('studentId', $data); + self::assertEmpty($data['subjectAverages'] ?? []); + } + + // ========================================================================= + // Helpers + // ========================================================================= + + /** + * @param list $roles + */ + private function createAuthenticatedClient(string $userId, array $roles): \ApiPlatform\Symfony\Bundle\Test\Client + { + $client = static::createClient(); + + $user = new SecurityUser( + userId: UserId::fromString($userId), + email: 'test@classeo.local', + hashedPassword: '', + tenantId: TenantId::fromString(self::TENANT_ID), + roles: $roles, + ); + + $client->loginUser($user, 'api'); + + return $client; + } + + private function seedFixtures(): void + { + $container = static::getContainer(); + /** @var Connection $connection */ + $connection = $container->get(Connection::class); + $tenantId = TenantId::fromString(self::TENANT_ID); + $now = new DateTimeImmutable(); + + $schoolId = '550e8400-e29b-41d4-a716-ff6655440001'; + $academicYearId = '550e8400-e29b-41d4-a716-ff6655440002'; + + // Seed users + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'teacher-sg@test.local', '', 'Test', 'Teacher', '[\"ROLE_PROF\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::TEACHER_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'student-sg@test.local', '', 'Alice', 'Durand', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::STUDENT_ID, 'tid' => self::TENANT_ID], + ); + $connection->executeStatement( + "INSERT INTO users (id, tenant_id, email, hashed_password, first_name, last_name, roles, statut, created_at, updated_at) + VALUES (:id, :tid, 'student2-sg@test.local', '', 'Bob', 'Martin', '[\"ROLE_ELEVE\"]', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::STUDENT2_ID, 'tid' => self::TENANT_ID], + ); + + // Seed class and subjects + $connection->executeStatement( + "INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, status, created_at, updated_at) + VALUES (:id, :tid, :sid, :ayid, 'Test-SG-Class', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::CLASS_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId, 'ayid' => $academicYearId], + ); + $connection->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tid, :sid, 'Mathématiques', 'MATH', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + $connection->executeStatement( + "INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) + VALUES (:id, :tid, :sid, 'Français', 'FRA', 'active', NOW(), NOW()) + ON CONFLICT (id) DO NOTHING", + ['id' => self::SUBJECT2_ID, 'tid' => self::TENANT_ID, 'sid' => $schoolId], + ); + $connection->executeStatement( + "INSERT INTO academic_periods (id, tenant_id, academic_year_id, period_type, sequence, label, start_date, end_date) + VALUES (:id, :tid, :ayid, 'trimester', 2, 'Trimestre 2', '2026-01-01', '2026-03-31') + ON CONFLICT (id) DO NOTHING", + ['id' => self::PERIOD_ID, 'tid' => self::TENANT_ID, 'ayid' => $academicYearId], + ); + + /** @var EvaluationRepository $evalRepo */ + $evalRepo = $container->get(EvaluationRepository::class); + /** @var GradeRepository $gradeRepo */ + $gradeRepo = $container->get(GradeRepository::class); + /** @var AverageCalculator $calculator */ + $calculator = $container->get(AverageCalculator::class); + /** @var EvaluationStatisticsRepository $statsRepo */ + $statsRepo = $container->get(EvaluationStatisticsRepository::class); + + // Evaluation 1: Published, Subject 1 (Maths), older date + $eval1 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'DS Mathématiques', + description: null, + evaluationDate: new DateTimeImmutable('2026-02-15'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(1.0), + now: $now, + ); + $eval1->publierNotes($now); + $eval1->pullDomainEvents(); + $evalRepo->save($eval1); + + foreach ([ + [self::STUDENT_ID, 16.0], + [self::STUDENT2_ID, 12.0], + ] as [$studentId, $value]) { + $grade = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval1->id, + studentId: UserId::fromString($studentId), + value: new GradeValue($value), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade->pullDomainEvents(); + $gradeRepo->save($grade); + } + + $stats1 = $calculator->calculateClassStatistics([16.0, 12.0]); + $statsRepo->save($eval1->id, $stats1); + + // Evaluation 2: Published, Subject 2 (Français), more recent date + $eval2 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT2_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Dictée', + description: null, + evaluationDate: new DateTimeImmutable('2026-03-01'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(2.0), + now: $now, + ); + $eval2->publierNotes($now); + $eval2->pullDomainEvents(); + $evalRepo->save($eval2); + + $grade2 = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval2->id, + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(14.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade2->pullDomainEvents(); + $gradeRepo->save($grade2); + + $stats2 = $calculator->calculateClassStatistics([14.0]); + $statsRepo->save($eval2->id, $stats2); + + // Evaluation 3: NOT published (grades should NOT appear for student) + $eval3 = Evaluation::creer( + tenantId: $tenantId, + classId: ClassId::fromString(self::CLASS_ID), + subjectId: SubjectId::fromString(self::SUBJECT_ID), + teacherId: UserId::fromString(self::TEACHER_ID), + title: 'Contrôle surprise', + description: null, + evaluationDate: new DateTimeImmutable('2026-03-10'), + gradeScale: new GradeScale(20), + coefficient: new Coefficient(0.5), + now: $now, + ); + // NOT published - don't call publierNotes() + $eval3->pullDomainEvents(); + $evalRepo->save($eval3); + $this->unpublishedEvalId = $eval3->id; + + $grade3 = Grade::saisir( + tenantId: $tenantId, + evaluationId: $eval3->id, + studentId: UserId::fromString(self::STUDENT_ID), + value: new GradeValue(8.0), + status: GradeStatus::GRADED, + gradeScale: new GradeScale(20), + createdBy: UserId::fromString(self::TEACHER_ID), + now: $now, + ); + $grade3->pullDomainEvents(); + $gradeRepo->save($grade3); + + // Save student averages for /me/averages endpoint + /** @var StudentAverageRepository $avgRepo */ + $avgRepo = $container->get(StudentAverageRepository::class); + $avgRepo->saveSubjectAverage( + $tenantId, + UserId::fromString(self::STUDENT_ID), + SubjectId::fromString(self::SUBJECT_ID), + self::PERIOD_ID, + 16.0, + 1, + ); + $avgRepo->saveGeneralAverage( + $tenantId, + UserId::fromString(self::STUDENT_ID), + self::PERIOD_ID, + 16.0, + ); + } +} diff --git a/frontend/e2e/admin-search-pagination.spec.ts b/frontend/e2e/admin-search-pagination.spec.ts index ed28ab0..67b80ee 100644 --- a/frontend/e2e/admin-search-pagination.spec.ts +++ b/frontend/e2e/admin-search-pagination.spec.ts @@ -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 }) => { diff --git a/frontend/e2e/branding.spec.ts b/frontend/e2e/branding.spec.ts index 1404a81..dfd12e3 100644 --- a/frontend/e2e/branding.spec.ts +++ b/frontend/e2e/branding.spec.ts @@ -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 diff --git a/frontend/e2e/calendar.spec.ts b/frontend/e2e/calendar.spec.ts index 1829537..535b54d 100644 --- a/frontend/e2e/calendar.spec.ts +++ b/frontend/e2e/calendar.spec.ts @@ -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({ diff --git a/frontend/e2e/competencies.spec.ts b/frontend/e2e/competencies.spec.ts index 0606a7e..9d44473 100644 --- a/frontend/e2e/competencies.spec.ts +++ b/frontend/e2e/competencies.spec.ts @@ -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 }); }); }); diff --git a/frontend/e2e/dashboard-responsive-nav.spec.ts b/frontend/e2e/dashboard-responsive-nav.spec.ts index 1623c20..b81bd21 100644 --- a/frontend/e2e/dashboard-responsive-nav.spec.ts +++ b/frontend/e2e/dashboard-responsive-nav.spec.ts @@ -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); }); }); diff --git a/frontend/e2e/parent-schedule.spec.ts b/frontend/e2e/parent-schedule.spec.ts index c2ef8eb..6dd8509 100644 --- a/frontend/e2e/parent-schedule.spec.ts +++ b/frontend/e2e/parent-schedule.spec.ts @@ -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(); diff --git a/frontend/e2e/sessions.spec.ts b/frontend/e2e/sessions.spec.ts index 1b0d778..6c8256c 100644 --- a/frontend/e2e/sessions.spec.ts +++ b/frontend/e2e/sessions.spec.ts @@ -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(); diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index df3ab90..806f872 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -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(); diff --git a/frontend/e2e/student-grades.spec.ts b/frontend/e2e/student-grades.spec.ts new file mode 100644 index 0000000..6e20140 --- /dev/null +++ b/frontend/e2e/student-grades.spec.ts @@ -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 }); + }); +}); diff --git a/frontend/e2e/student-schedule.spec.ts b/frontend/e2e/student-schedule.spec.ts index 6059aac..dee412b 100644 --- a/frontend/e2e/student-schedule.spec.ts +++ b/frontend/e2e/student-schedule.spec.ts @@ -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(); }); diff --git a/frontend/e2e/teacher-replacements.spec.ts b/frontend/e2e/teacher-replacements.spec.ts index ef29f41..90dd3c7 100644 --- a/frontend/e2e/teacher-replacements.spec.ts +++ b/frontend/e2e/teacher-replacements.spec.ts @@ -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 }); }); }); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 104f790..3c96f58 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -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', diff --git a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte index 450558c..dc09837 100644 --- a/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte +++ b/frontend/src/lib/components/organisms/Dashboard/DashboardStudent.svelte @@ -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([]); let homeworkLoading = $state(false); + // Grades widget state + let recentGrades = $state([]); + let studentAverages = $state(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(null); @@ -116,6 +152,10 @@ if (!isEleve) return; void loadTodaySchedule(); void loadHomeworks(); + void loadGrades(); + return () => { + if (gradeSeenTimerId !== null) window.clearTimeout(gradeSeenTimerId); + }; }); @@ -179,11 +219,51 @@ - {#if hasRealData} + {#if isEleve} + {#if gradesLoading} + + {:else if recentGrades.length === 0} +

Aucune note publiée

+ {:else} + {#if studentAverages?.generalAverage != null} +
+ Moyenne générale + + {studentAverages.generalAverage.toFixed(1)}/20 + +
+ {/if} +
    + {#each recentGrades as grade} +
  • +
    + {grade.subjectName ?? 'Matière'} + {#if isGradeNew(grade.id)} + Nouveau + {/if} + {#if grade.status === 'graded' && grade.value != null} + + {grade.value}/{grade.gradeScale} + + {:else if grade.status === 'absent'} + Absent + {:else if grade.status === 'dispensed'} + Dispensé + {/if} +
    + {grade.evaluationTitle} +
  • + {/each} +
+ + Voir toutes les notes → + + {/if} + {:else if hasRealData} {#if isLoading} {: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; diff --git a/frontend/src/lib/features/grades/api/studentGrades.ts b/frontend/src/lib/features/grades/api/studentGrades.ts new file mode 100644 index 0000000..00f6ed9 --- /dev/null +++ b/frontend/src/lib/features/grades/api/studentGrades.ts @@ -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 { + 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 { + 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 { + 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(); +} diff --git a/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts b/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts new file mode 100644 index 0000000..2285e02 --- /dev/null +++ b/frontend/src/lib/features/grades/stores/gradePreferences.svelte.ts @@ -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('immediate'); +let seenGradeIds = $state>(new Set()); +let revealedGradeIds = $state>(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'; +} diff --git a/frontend/src/routes/dashboard/+layout.svelte b/frontend/src/routes/dashboard/+layout.svelte index 164661a..2ff0487 100644 --- a/frontend/src/routes/dashboard/+layout.svelte +++ b/frontend/src/routes/dashboard/+layout.svelte @@ -109,6 +109,7 @@ {/if} {#if isEleve} Mon EDT + Mes notes Compétences {/if} {#if isParent} @@ -164,6 +165,9 @@ Mon emploi du temps + + Mes notes + Compétences diff --git a/frontend/src/routes/dashboard/student-grades/+page.svelte b/frontend/src/routes/dashboard/student-grades/+page.svelte new file mode 100644 index 0000000..6fa4abf --- /dev/null +++ b/frontend/src/routes/dashboard/student-grades/+page.svelte @@ -0,0 +1,691 @@ + + + + +
+ + + {#if error} + + {/if} + + {#if isLoading} + + {:else if grades.length === 0} +
+

Aucune note publiée pour le moment.

+
+ {:else} + + {#if averages && averages.subjectAverages.length > 0} +
+

Moyennes par matière

+
+ {#each averages.subjectAverages as avg} + + {/each} +
+
+ {/if} + + +
+

Dernières notes

+
    + {#each grades as grade (grade.id)} + {@const discover = isDiscoverMode() && !isGradeRevealed(grade.id)} + {@const isNew = isGradeNew(grade.id)} +
  • +
    + + {formatDate(grade.evaluationDate)} + {#if isNew} + Nouveau + {/if} +
    +
    + {grade.evaluationTitle} + {#if discover} + + {:else if grade.status === 'graded' && grade.value != null} + + {grade.value}/{grade.gradeScale} + + {:else if grade.status === 'absent'} + Absent + {:else if grade.status === 'dispensed'} + Dispensé + {/if} +
    + {#if !discover && grade.classAverage != null} +
    + Moy. classe : {grade.classAverage.toFixed(1)} + {#if grade.classMin != null && grade.classMax != null} + Min : {grade.classMin.toFixed(1)} / Max : {grade.classMax.toFixed(1)} + {/if} +
    + {/if} + {#if !discover && grade.appreciation} +

    {grade.appreciation}

    + {/if} +
    + Coeff. {grade.coefficient} +
    +
  • + {/each} +
+
+ {/if} +
+ + +{#if selectedSubjectId} + + +{/if} + +