feat: Permettre aux élèves de consulter leur emploi du temps
Les élèves n'avaient aucun moyen de voir leur emploi du temps depuis l'application. Cette fonctionnalité ajoute une page dédiée avec deux modes de visualisation (jour et semaine), la navigation temporelle, et le détail des cours au tap. Le backend résout l'EDT de l'élève en chaînant : affectation classe → créneaux récurrents + exceptions + calendrier scolaire → enrichissement des noms (matières/enseignants). Le frontend utilise un cache offline (Workbox NetworkFirst) pour rester consultable hors connexion.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour obtenir le calendrier scolaire de l'année courante.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface CurrentCalendarProvider
|
||||
{
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
/**
|
||||
* Port pour lire les noms d'affichage des matières et enseignants.
|
||||
*
|
||||
* Permet à la couche Application de résoudre les noms sans dépendre
|
||||
* directement des repositories du bounded context Administration.
|
||||
*/
|
||||
interface ScheduleDisplayReader
|
||||
{
|
||||
/**
|
||||
* @param string ...$subjectIds Identifiants des matières
|
||||
*
|
||||
* @return array<string, string> Map subjectId => nom de la matière
|
||||
*/
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array;
|
||||
|
||||
/**
|
||||
* @param string ...$teacherIds Identifiants des enseignants
|
||||
*
|
||||
* @return array<string, string> Map teacherId => "Prénom Nom"
|
||||
*/
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Port;
|
||||
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
/**
|
||||
* Port pour résoudre la classe d'un élève pour l'année scolaire courante.
|
||||
*
|
||||
* L'implémentation résout l'année académique courante de façon transparente.
|
||||
*/
|
||||
interface StudentClassReader
|
||||
{
|
||||
/**
|
||||
* @return string|null L'identifiant de la classe, ou null si l'élève n'est affecté à aucune classe
|
||||
*/
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
|
||||
use function array_map;
|
||||
use function array_unique;
|
||||
use function array_values;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
|
||||
|
||||
#[AsMessageHandler(bus: 'query.bus')]
|
||||
final readonly class GetStudentScheduleHandler
|
||||
{
|
||||
public function __construct(
|
||||
private StudentClassReader $studentClassReader,
|
||||
private ScheduleResolver $scheduleResolver,
|
||||
private CurrentCalendarProvider $calendarProvider,
|
||||
private ScheduleDisplayReader $displayReader,
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array<StudentScheduleSlotDto> */
|
||||
public function __invoke(GetStudentScheduleQuery $query): array
|
||||
{
|
||||
$tenantId = TenantId::fromString($query->tenantId);
|
||||
|
||||
$classId = $this->studentClassReader->currentClassId($query->studentId, $tenantId);
|
||||
|
||||
if ($classId === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$date = new DateTimeImmutable($query->date);
|
||||
$weekStart = $this->mondayOfWeek($date);
|
||||
|
||||
$calendar = $this->calendarProvider->forCurrentYear($tenantId);
|
||||
|
||||
$resolved = $this->scheduleResolver->resolveForWeek(
|
||||
ClassId::fromString($classId),
|
||||
$weekStart,
|
||||
$tenantId,
|
||||
$calendar,
|
||||
);
|
||||
|
||||
return $this->enrichSlots($resolved, $query->tenantId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<ResolvedScheduleSlot> $slots
|
||||
*
|
||||
* @return array<StudentScheduleSlotDto>
|
||||
*/
|
||||
private function enrichSlots(array $slots, string $tenantId): array
|
||||
{
|
||||
if ($slots === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$subjectIds = array_values(array_unique(
|
||||
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->subjectId, $slots),
|
||||
));
|
||||
$teacherIds = array_values(array_unique(
|
||||
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
|
||||
));
|
||||
|
||||
$subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds);
|
||||
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
|
||||
|
||||
return array_map(
|
||||
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
|
||||
$s,
|
||||
$subjectNames[(string) $s->subjectId] ?? '',
|
||||
$teacherNames[(string) $s->teacherId] ?? '',
|
||||
),
|
||||
$slots,
|
||||
);
|
||||
}
|
||||
|
||||
private function mondayOfWeek(DateTimeImmutable $date): DateTimeImmutable
|
||||
{
|
||||
$dayOfWeek = (int) $date->format('N');
|
||||
|
||||
if ($dayOfWeek === 1) {
|
||||
return $date;
|
||||
}
|
||||
|
||||
return $date->modify('-' . ($dayOfWeek - 1) . ' days');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use InvalidArgumentException;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
final readonly class GetStudentScheduleQuery
|
||||
{
|
||||
public function __construct(
|
||||
public string $studentId,
|
||||
public string $tenantId,
|
||||
public string $date,
|
||||
) {
|
||||
if (DateTimeImmutable::createFromFormat('Y-m-d', $date) === false) {
|
||||
throw new InvalidArgumentException(sprintf('Date invalide : "%s". Format attendu : YYYY-MM-DD.', $date));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Scolarite\Domain\Model\Schedule\ResolvedScheduleSlot;
|
||||
|
||||
final readonly class StudentScheduleSlotDto
|
||||
{
|
||||
public function __construct(
|
||||
public string $slotId,
|
||||
public string $date,
|
||||
public int $dayOfWeek,
|
||||
public string $startTime,
|
||||
public string $endTime,
|
||||
public string $subjectId,
|
||||
public string $subjectName,
|
||||
public string $teacherId,
|
||||
public string $teacherName,
|
||||
public ?string $room,
|
||||
public bool $isModified,
|
||||
public ?string $exceptionId,
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromResolved(
|
||||
ResolvedScheduleSlot $slot,
|
||||
string $subjectName,
|
||||
string $teacherName,
|
||||
): self {
|
||||
return new self(
|
||||
slotId: (string) $slot->slotId,
|
||||
date: $slot->date->format('Y-m-d'),
|
||||
dayOfWeek: $slot->dayOfWeek->value,
|
||||
startTime: $slot->timeSlot->startTime,
|
||||
endTime: $slot->timeSlot->endTime,
|
||||
subjectId: (string) $slot->subjectId,
|
||||
subjectName: $subjectName,
|
||||
teacherId: (string) $slot->teacherId,
|
||||
teacherName: $teacherName,
|
||||
room: $slot->room,
|
||||
isModified: $slot->isModified,
|
||||
exceptionId: $slot->exceptionId !== null ? (string) $slot->exceptionId : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Api\Controller;
|
||||
|
||||
use App\Administration\Infrastructure\Security\SecurityUser;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||
use App\Scolarite\Infrastructure\Security\ScheduleSlotVoter;
|
||||
|
||||
use function array_filter;
|
||||
use function array_map;
|
||||
use function array_values;
|
||||
use function date;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
use function usort;
|
||||
|
||||
/**
|
||||
* Endpoints de consultation de l'emploi du temps pour l'élève connecté.
|
||||
*
|
||||
* @see Story 4.3 - Consultation EDT par l'Élève
|
||||
* @see FR29 - Consulter emploi du temps (élève)
|
||||
*/
|
||||
#[IsGranted(ScheduleSlotVoter::VIEW)]
|
||||
final readonly class StudentScheduleController
|
||||
{
|
||||
public function __construct(
|
||||
private Security $security,
|
||||
private GetStudentScheduleHandler $handler,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* EDT du jour pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/day/{date}', name: 'api_student_schedule_day', methods: ['GET'])]
|
||||
public function day(string $date): JsonResponse
|
||||
{
|
||||
$slots = $this->resolveSchedule($date);
|
||||
|
||||
$daySlots = array_values(array_filter(
|
||||
$slots,
|
||||
static fn (StudentScheduleSlotDto $s): bool => $s->date === $date,
|
||||
));
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlots($daySlots)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* EDT de la semaine pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/week/{date}', name: 'api_student_schedule_week', methods: ['GET'])]
|
||||
public function week(string $date): JsonResponse
|
||||
{
|
||||
$slots = $this->resolveSchedule($date);
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlots($slots)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prochain cours pour l'élève connecté.
|
||||
*/
|
||||
#[Route('/api/me/schedule/next-class', name: 'api_student_schedule_next_class', methods: ['GET'])]
|
||||
public function nextClass(): JsonResponse
|
||||
{
|
||||
$today = date('Y-m-d');
|
||||
$now = date('H:i');
|
||||
|
||||
$slots = $this->resolveSchedule($today);
|
||||
|
||||
// Chercher le prochain cours : d'abord aujourd'hui après l'heure actuelle
|
||||
$nextSlot = $this->findNextSlot($slots, $today, $now);
|
||||
|
||||
if ($nextSlot === null) {
|
||||
return new JsonResponse(['data' => null]);
|
||||
}
|
||||
|
||||
return new JsonResponse(['data' => $this->serializeSlot($nextSlot)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<StudentScheduleSlotDto>
|
||||
*/
|
||||
private function resolveSchedule(string $date): array
|
||||
{
|
||||
$user = $this->getSecurityUser();
|
||||
|
||||
try {
|
||||
return ($this->handler)(new GetStudentScheduleQuery(
|
||||
studentId: $user->userId(),
|
||||
tenantId: $user->tenantId(),
|
||||
date: $date,
|
||||
));
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new BadRequestHttpException($e->getMessage(), $e);
|
||||
} catch (RuntimeException $e) {
|
||||
throw new ServiceUnavailableHttpException(null, "L'emploi du temps n'est pas encore disponible.", $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<StudentScheduleSlotDto> $slots
|
||||
*/
|
||||
private function findNextSlot(array $slots, string $today, string $now): ?StudentScheduleSlotDto
|
||||
{
|
||||
$candidates = [];
|
||||
|
||||
foreach ($slots as $slot) {
|
||||
// Cours aujourd'hui qui n'a pas encore commencé
|
||||
if ($slot->date === $today && $slot->startTime > $now) {
|
||||
$candidates[] = $slot;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Cours des jours suivants de la semaine
|
||||
if ($slot->date > $today) {
|
||||
$candidates[] = $slot;
|
||||
}
|
||||
}
|
||||
|
||||
if ($candidates === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
usort($candidates, static function (StudentScheduleSlotDto $a, StudentScheduleSlotDto $b): int {
|
||||
$cmp = $a->date <=> $b->date;
|
||||
|
||||
return $cmp !== 0 ? $cmp : $a->startTime <=> $b->startTime;
|
||||
});
|
||||
|
||||
return $candidates[0];
|
||||
}
|
||||
|
||||
private function getSecurityUser(): SecurityUser
|
||||
{
|
||||
$user = $this->security->getUser();
|
||||
|
||||
if (!$user instanceof SecurityUser) {
|
||||
throw new AccessDeniedHttpException('Authentification requise.');
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<StudentScheduleSlotDto> $slots
|
||||
*
|
||||
* @return array<array<string, mixed>>
|
||||
*/
|
||||
private function serializeSlots(array $slots): array
|
||||
{
|
||||
return array_map($this->serializeSlot(...), $slots);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeSlot(StudentScheduleSlotDto $slot): array
|
||||
{
|
||||
return [
|
||||
'slotId' => $slot->slotId,
|
||||
'date' => $slot->date,
|
||||
'dayOfWeek' => $slot->dayOfWeek,
|
||||
'startTime' => $slot->startTime,
|
||||
'endTime' => $slot->endTime,
|
||||
'subjectId' => $slot->subjectId,
|
||||
'subjectName' => $slot->subjectName,
|
||||
'teacherId' => $slot->teacherId,
|
||||
'teacherName' => $slot->teacherName,
|
||||
'room' => $slot->room,
|
||||
'isModified' => $slot->isModified,
|
||||
'exceptionId' => $slot->exceptionId,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
|
||||
* Voter pour les autorisations sur l'emploi du temps.
|
||||
*
|
||||
* Seuls ADMIN et SUPER_ADMIN peuvent gérer l'EDT.
|
||||
* PROF et VIE_SCOLAIRE peuvent le consulter.
|
||||
* PROF, VIE_SCOLAIRE et ELEVE peuvent le consulter.
|
||||
*
|
||||
* @extends Voter<string, null>
|
||||
*/
|
||||
@@ -68,6 +68,7 @@ final class ScheduleSlotVoter extends Voter
|
||||
Role::ADMIN->value,
|
||||
Role::PROF->value,
|
||||
Role::VIE_SCOLAIRE->value,
|
||||
Role::ELEVE->value,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendarRepository;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Fournit le calendrier scolaire de l'année courante.
|
||||
*/
|
||||
final readonly class CurrentYearCalendarProvider implements CurrentCalendarProvider
|
||||
{
|
||||
public function __construct(
|
||||
private SchoolCalendarRepository $calendarRepository,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
throw new RuntimeException(
|
||||
'Aucune année scolaire configurée pour le tenant ' . $tenantId . '. '
|
||||
. 'Veuillez configurer une année scolaire avant de consulter l\'emploi du temps.',
|
||||
);
|
||||
}
|
||||
|
||||
$yearId = AcademicYearId::fromString($academicYearId);
|
||||
|
||||
return $this->calendarRepository->findByTenantAndYear($tenantId, $yearId)
|
||||
?? SchoolCalendar::initialiser($tenantId, $yearId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Administration\Domain\Repository\ClassAssignmentRepository;
|
||||
use App\Administration\Infrastructure\Service\CurrentAcademicYearResolver;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Résout la classe d'un élève pour l'année scolaire courante.
|
||||
*/
|
||||
final readonly class CurrentYearStudentClassReader implements StudentClassReader
|
||||
{
|
||||
public function __construct(
|
||||
private ClassAssignmentRepository $classAssignmentRepository,
|
||||
private CurrentAcademicYearResolver $academicYearResolver,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string
|
||||
{
|
||||
$academicYearId = $this->academicYearResolver->resolve('current');
|
||||
|
||||
if ($academicYearId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$assignment = $this->classAssignmentRepository->findByStudent(
|
||||
UserId::fromString($studentId),
|
||||
AcademicYearId::fromString($academicYearId),
|
||||
$tenantId,
|
||||
);
|
||||
|
||||
if ($assignment === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $assignment->classId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Scolarite\Infrastructure\Service;
|
||||
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use Doctrine\DBAL\ArrayParameterType;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Override;
|
||||
|
||||
/**
|
||||
* Résout les noms d'affichage des matières et enseignants via des requêtes batch.
|
||||
*/
|
||||
final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayReader
|
||||
{
|
||||
public function __construct(
|
||||
private Connection $connection,
|
||||
) {
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
if ($subjectIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT id, name FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId',
|
||||
['ids' => $subjectIds, 'tenantId' => $tenantId],
|
||||
['ids' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $name */
|
||||
$name = $row['name'];
|
||||
$names[$id] = $name;
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
|
||||
#[Override]
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
if ($teacherIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$rows = $this->connection->fetchAllAssociative(
|
||||
'SELECT id, first_name, last_name FROM users WHERE id IN (:ids) AND tenant_id = :tenantId',
|
||||
['ids' => $teacherIds, 'tenantId' => $tenantId],
|
||||
['ids' => ArrayParameterType::STRING],
|
||||
);
|
||||
|
||||
$names = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
/** @var string $id */
|
||||
$id = $row['id'];
|
||||
/** @var string $firstName */
|
||||
$firstName = $row['first_name'];
|
||||
/** @var string $lastName */
|
||||
$lastName = $row['last_name'];
|
||||
$names[$id] = $firstName . ' ' . $lastName;
|
||||
}
|
||||
|
||||
return $names;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentSchedule;
|
||||
|
||||
use App\Administration\Domain\Model\SchoolCalendar\SchoolCalendar;
|
||||
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
|
||||
use App\Administration\Domain\Model\SchoolClass\ClassId;
|
||||
use App\Administration\Domain\Model\Subject\SubjectId;
|
||||
use App\Administration\Domain\Model\User\UserId;
|
||||
use App\Scolarite\Application\Port\CurrentCalendarProvider;
|
||||
use App\Scolarite\Application\Port\ScheduleDisplayReader;
|
||||
use App\Scolarite\Application\Port\StudentClassReader;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleHandler;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\GetStudentScheduleQuery;
|
||||
use App\Scolarite\Application\Query\GetStudentSchedule\StudentScheduleSlotDto;
|
||||
use App\Scolarite\Application\Service\ScheduleResolver;
|
||||
use App\Scolarite\Domain\Model\Schedule\DayOfWeek;
|
||||
use App\Scolarite\Domain\Model\Schedule\ScheduleSlot;
|
||||
use App\Scolarite\Domain\Model\Schedule\TimeSlot;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleExceptionRepository;
|
||||
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryScheduleSlotRepository;
|
||||
use App\Shared\Domain\Tenant\TenantId;
|
||||
use DateTimeImmutable;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class GetStudentScheduleHandlerTest extends TestCase
|
||||
{
|
||||
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
|
||||
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
|
||||
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
|
||||
private const string SUBJECT_ID = '550e8400-e29b-41d4-a716-446655440030';
|
||||
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
|
||||
|
||||
private InMemoryScheduleSlotRepository $slotRepository;
|
||||
private InMemoryScheduleExceptionRepository $exceptionRepository;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->slotRepository = new InMemoryScheduleSlotRepository();
|
||||
$this->exceptionRepository = new InMemoryScheduleExceptionRepository();
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsEmptyWhenStudentHasNoClass(): void
|
||||
{
|
||||
$handler = $this->createHandler(classId: null);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertSame([], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsScheduleForStudentClass(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
$this->saveRecurringSlot(DayOfWeek::TUESDAY, '10:00', '11:00');
|
||||
|
||||
$handler = $this->createHandler(classId: self::CLASS_ID);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02', // Monday
|
||||
));
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertContainsOnlyInstancesOf(StudentScheduleSlotDto::class, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function enrichesSlotsWithSubjectAndTeacherNames(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$handler = $this->createHandler(
|
||||
classId: self::CLASS_ID,
|
||||
subjectNames: [self::SUBJECT_ID => 'Mathématiques'],
|
||||
teacherNames: [self::TEACHER_ID => 'Jean Dupont'],
|
||||
);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('Mathématiques', $result[0]->subjectName);
|
||||
self::assertSame('Jean Dupont', $result[0]->teacherName);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function computesMondayFromAnyDayOfWeek(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00');
|
||||
|
||||
$handler = $this->createHandler(classId: self::CLASS_ID);
|
||||
|
||||
// Wednesday of the same week → should still return Monday's slot
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-04', // Wednesday
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
self::assertSame('2026-03-02', $result[0]->date);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function returnsCorrectDtoFields(): void
|
||||
{
|
||||
$this->saveRecurringSlot(DayOfWeek::MONDAY, '08:00', '09:00', 'Salle 101');
|
||||
|
||||
$handler = $this->createHandler(
|
||||
classId: self::CLASS_ID,
|
||||
subjectNames: [self::SUBJECT_ID => 'Français'],
|
||||
teacherNames: [self::TEACHER_ID => 'Marie Martin'],
|
||||
);
|
||||
|
||||
$result = $handler(new GetStudentScheduleQuery(
|
||||
studentId: self::STUDENT_ID,
|
||||
tenantId: self::TENANT_ID,
|
||||
date: '2026-03-02',
|
||||
));
|
||||
|
||||
self::assertCount(1, $result);
|
||||
$dto = $result[0];
|
||||
self::assertSame('2026-03-02', $dto->date);
|
||||
self::assertSame(1, $dto->dayOfWeek);
|
||||
self::assertSame('08:00', $dto->startTime);
|
||||
self::assertSame('09:00', $dto->endTime);
|
||||
self::assertSame(self::SUBJECT_ID, $dto->subjectId);
|
||||
self::assertSame('Français', $dto->subjectName);
|
||||
self::assertSame(self::TEACHER_ID, $dto->teacherId);
|
||||
self::assertSame('Marie Martin', $dto->teacherName);
|
||||
self::assertSame('Salle 101', $dto->room);
|
||||
self::assertFalse($dto->isModified);
|
||||
self::assertNull($dto->exceptionId);
|
||||
}
|
||||
|
||||
private function saveRecurringSlot(
|
||||
DayOfWeek $day,
|
||||
string $start,
|
||||
string $end,
|
||||
?string $room = null,
|
||||
): void {
|
||||
$slot = ScheduleSlot::creer(
|
||||
tenantId: TenantId::fromString(self::TENANT_ID),
|
||||
classId: ClassId::fromString(self::CLASS_ID),
|
||||
subjectId: SubjectId::fromString(self::SUBJECT_ID),
|
||||
teacherId: UserId::fromString(self::TEACHER_ID),
|
||||
dayOfWeek: $day,
|
||||
timeSlot: new TimeSlot($start, $end),
|
||||
room: $room,
|
||||
isRecurring: true,
|
||||
now: new DateTimeImmutable('2026-01-01'),
|
||||
recurrenceStart: new DateTimeImmutable('2026-01-01'),
|
||||
);
|
||||
|
||||
$this->slotRepository->save($slot);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $subjectNames
|
||||
* @param array<string, string> $teacherNames
|
||||
*/
|
||||
private function createHandler(
|
||||
?string $classId = null,
|
||||
array $subjectNames = [],
|
||||
array $teacherNames = [],
|
||||
): GetStudentScheduleHandler {
|
||||
$tenantId = TenantId::fromString(self::TENANT_ID);
|
||||
|
||||
$studentClassReader = new class($classId) implements StudentClassReader {
|
||||
public function __construct(private ?string $classId)
|
||||
{
|
||||
}
|
||||
|
||||
public function currentClassId(string $studentId, TenantId $tenantId): ?string
|
||||
{
|
||||
return $this->classId;
|
||||
}
|
||||
};
|
||||
|
||||
$calendarProvider = new class($tenantId) implements CurrentCalendarProvider {
|
||||
public function __construct(private TenantId $tenantId)
|
||||
{
|
||||
}
|
||||
|
||||
public function forCurrentYear(TenantId $tenantId): SchoolCalendar
|
||||
{
|
||||
return SchoolCalendar::initialiser($this->tenantId, AcademicYearId::generate());
|
||||
}
|
||||
};
|
||||
|
||||
$displayReader = new class($subjectNames, $teacherNames) implements ScheduleDisplayReader {
|
||||
/** @param array<string, string> $subjects @param array<string, string> $teachers */
|
||||
public function __construct(
|
||||
private array $subjects,
|
||||
private array $teachers,
|
||||
) {
|
||||
}
|
||||
|
||||
public function subjectNames(string $tenantId, string ...$subjectIds): array
|
||||
{
|
||||
return $this->subjects;
|
||||
}
|
||||
|
||||
public function teacherNames(string $tenantId, string ...$teacherIds): array
|
||||
{
|
||||
return $this->teachers;
|
||||
}
|
||||
};
|
||||
|
||||
return new GetStudentScheduleHandler(
|
||||
$studentClassReader,
|
||||
new ScheduleResolver($this->slotRepository, $this->exceptionRepository),
|
||||
$calendarProvider,
|
||||
$displayReader,
|
||||
);
|
||||
}
|
||||
}
|
||||
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal file
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
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-dash-nav-student@example.com';
|
||||
const STUDENT_PASSWORD = 'DashNavStudent123';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
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: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Responsive Navigation', () => {
|
||||
test.beforeAll(async () => {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// MOBILE (375x667)
|
||||
// =========================================================================
|
||||
test.describe('Mobile (375x667)', () => {
|
||||
test.use({ viewport: { width: 375, height: 667 } });
|
||||
|
||||
test('shows hamburger button and hides desktop nav', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('opens drawer via hamburger and shows nav links', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Should show navigation links
|
||||
await expect(drawer.getByText('Tableau de bord')).toBeVisible();
|
||||
await expect(drawer.getByText('Mon emploi du temps')).toBeVisible();
|
||||
await expect(drawer.getByText('Paramètres')).toBeVisible();
|
||||
});
|
||||
|
||||
test('closes drawer via close button', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /fermer le menu/i }).click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('closes drawer on overlay click', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const overlay = page.locator('.mobile-overlay');
|
||||
await overlay.click({ position: { x: 350, y: 300 } });
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates via mobile drawer and closes it', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await drawer.getByText('Mon emploi du temps').click();
|
||||
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard\/schedule/);
|
||||
});
|
||||
|
||||
test('shows logout button in drawer footer', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const logoutButton = drawer.locator('.mobile-logout');
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await expect(logoutButton).toHaveText(/déconnexion/i);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// DESKTOP (1280x800)
|
||||
// =========================================================================
|
||||
test.describe('Desktop (1280x800)', () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test('hides hamburger and shows desktop nav', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).not.toBeVisible();
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('desktop nav shows schedule link for student', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav.getByText('Mon EDT')).toBeVisible({ timeout: 10000 });
|
||||
await expect(desktopNav.getByText('Tableau de bord')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
|
||||
// Teacher should not see admin-specific navigation in the dashboard layout
|
||||
// The dashboard header should not have admin links like "Utilisateurs"
|
||||
const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' });
|
||||
const adminUsersLink = page.locator('.desktop-nav').getByRole('link', { name: 'Utilisateurs' });
|
||||
await expect(adminUsersLink).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
556
frontend/e2e/student-schedule.spec.ts
Normal file
556
frontend/e2e/student-schedule.spec.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
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-schedule@example.com';
|
||||
const STUDENT_PASSWORD = 'StudentSchedule123';
|
||||
const TEACHER_EMAIL = 'e2e-student-sched-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'TeacherSchedule123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ISO day of week (1=Monday ... 5=Friday) for the current day,
|
||||
* clamped to weekdays for schedule slot seeding.
|
||||
*/
|
||||
function currentWeekdayIso(): number {
|
||||
const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat
|
||||
if (jsDay === 0) return 1; // Sunday → use Monday
|
||||
if (jsDay === 6) return 5; // Saturday → use Friday
|
||||
return jsDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of day-back navigations needed to reach the seeded weekday.
|
||||
* 0 on weekdays, 1 on Saturday (→ Friday), 2 on Sunday (→ Friday).
|
||||
*/
|
||||
function daysBackToSeededWeekday(): number {
|
||||
const jsDay = new Date().getDay();
|
||||
if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday
|
||||
if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the French day name for the target seeded weekday.
|
||||
*/
|
||||
function seededDayName(): string {
|
||||
const jsDay = new Date().getDay();
|
||||
// Saturday → Friday, Sunday → Friday, else today
|
||||
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
|
||||
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* On weekends, navigate back to the weekday where schedule slots were seeded.
|
||||
* Must be called after the schedule page has loaded.
|
||||
*
|
||||
* Webkit needs time after page render for Svelte 5 event delegation to hydrate.
|
||||
* We retry clicking until the day title changes, with a timeout.
|
||||
*/
|
||||
async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
||||
const back = daysBackToSeededWeekday();
|
||||
if (back === 0) return;
|
||||
|
||||
const targetDay = seededDayName();
|
||||
const targetPattern = new RegExp(targetDay, 'i');
|
||||
const prevBtn = page.getByLabel('Précédent');
|
||||
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate
|
||||
const deadline = Date.now() + 15000;
|
||||
let navigated = false;
|
||||
while (Date.now() < deadline && !navigated) {
|
||||
for (let i = 0; i < back; i++) {
|
||||
await prevBtn.click();
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
const title = await page.locator('.day-title').textContent();
|
||||
if (title && targetPattern.test(title)) {
|
||||
navigated = true;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
|
||||
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: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create student user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
// Ensure class exists
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-StudentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Ensure subject exists
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-StudentSched-Maths', 'E2ESTUMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-StudentSched-Français', 'E2ESTUFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Clean up schedule data for this tenant
|
||||
try {
|
||||
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-StudentSched-6A' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Clean up calendar entries to prevent holidays/vacations from blocking schedule resolution
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// 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) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c ` +
|
||||
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create schedule slots for the class on today's weekday
|
||||
const dayOfWeek = currentWeekdayIso();
|
||||
runSql(
|
||||
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '09:00', '10:00', 'A101', true, NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2ESTUMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
runSql(
|
||||
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:15', '11:15', 'B202', true, NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2ESTUFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC1: Day View
|
||||
// ======================================================================
|
||||
test.describe('AC1: Day View', () => {
|
||||
test('student can navigate to schedule page', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('day view is the default view', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Day toggle should be active
|
||||
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
||||
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('day view shows schedule slots for today', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load
|
||||
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Should see both slots
|
||||
await expect(slots).toHaveCount(2);
|
||||
|
||||
// Verify slot content
|
||||
await expect(page.getByText('E2E-StudentSched-Maths')).toBeVisible();
|
||||
await expect(page.getByText('E2E-StudentSched-Français')).toBeVisible();
|
||||
await expect(page.getByText('A101')).toBeVisible();
|
||||
await expect(page.getByText('B202')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC2: Day Navigation
|
||||
// ======================================================================
|
||||
test.describe('AC2: Day Navigation', () => {
|
||||
test('navigating to a day with no courses shows empty message', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// On weekends, the current day already has no courses — verify directly
|
||||
const back = daysBackToSeededWeekday();
|
||||
if (back > 0) {
|
||||
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for today's slots to fully load before navigating
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Navigate forward enough days to reach a day with no seeded slots.
|
||||
// Slots are only seeded on today's weekday, so +1 day is guaranteed empty.
|
||||
await page.getByLabel('Suivant').click();
|
||||
|
||||
// The day view should show the empty-state message
|
||||
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can navigate to next day and back', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load on the seeded day
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Navigate to next day
|
||||
await page.getByLabel('Suivant').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Then navigate back
|
||||
await page.getByLabel('Précédent').click();
|
||||
|
||||
// Slots should be visible again
|
||||
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC3: Week View
|
||||
// ======================================================================
|
||||
test.describe('AC3: Week View', () => {
|
||||
test('can switch to week view and see grid', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// Week grid should show day headers (proves week view rendered)
|
||||
// Use exact match to avoid strict mode violation with mobile list labels ("Lun 2" etc.)
|
||||
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Mar', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Mer', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Jeu', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
||||
|
||||
// Week slots should be visible (scope to desktop grid to avoid hidden mobile slots)
|
||||
const weekSlots = page.locator('.week-slot-desktop[data-testid="week-slot"]');
|
||||
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('week view shows mobile list layout on small viewport', async ({ page }) => {
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// Mobile list should be visible, desktop grid should be hidden
|
||||
const weekList = page.locator('.week-list');
|
||||
const weekGrid = page.locator('.week-grid');
|
||||
await expect(weekList).toBeVisible({ timeout: 15000 });
|
||||
await expect(weekGrid).not.toBeVisible();
|
||||
|
||||
// Should show day sections with slot count
|
||||
await expect(page.getByText(/\d+ cours/).first()).toBeVisible();
|
||||
|
||||
// Week slots should be visible in mobile layout
|
||||
const weekSlots = page.locator('[data-testid="week-slot"]');
|
||||
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('week view shows desktop grid on large viewport', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// 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(weekList).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can switch back to day view from week view', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week
|
||||
await page.locator('.view-toggle button', { hasText: 'Semaine' }).click();
|
||||
await expect(page.locator('.week-slot-desktop[data-testid="week-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch back to day
|
||||
await page.locator('.view-toggle button', { hasText: 'Jour' }).click();
|
||||
|
||||
// Day slots should be visible again (proves day view rendered)
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC4: Slot Details
|
||||
// ======================================================================
|
||||
test.describe('AC4: Slot Details', () => {
|
||||
test('clicking a slot opens details modal', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the slot
|
||||
await firstSlot.click();
|
||||
|
||||
// Modal should appear with course details
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show subject, teacher, room, time
|
||||
await expect(dialog.getByText('E2E-StudentSched-Maths')).toBeVisible();
|
||||
await expect(dialog.getByText('09:00 - 10:00')).toBeVisible();
|
||||
await expect(dialog.getByText('A101')).toBeVisible();
|
||||
});
|
||||
|
||||
test('details modal closes with Escape key', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('details modal closes when clicking overlay', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close by clicking the overlay (outside the card)
|
||||
await page.locator('.overlay').click({ position: { x: 10, y: 10 } });
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC5: Offline Mode
|
||||
// ======================================================================
|
||||
test.describe('AC5: Offline Mode', () => {
|
||||
test('shows offline banner when network is lost', async ({ page, context }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for schedule to load (data is now cached by the browser)
|
||||
await expect(
|
||||
page.locator('[data-testid="schedule-slot"]').first()
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Simulate going offline — triggers window 'offline' event
|
||||
await context.setOffline(true);
|
||||
|
||||
// The offline banner should appear
|
||||
const offlineBanner = page.locator('.offline-banner[role="status"]');
|
||||
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
|
||||
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
|
||||
await expect(offlineBanner.getByText(/Dernière sync/)).toBeVisible();
|
||||
|
||||
// Restore online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Banner should disappear
|
||||
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Navigation link
|
||||
// ======================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('schedule link is visible in dashboard header', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
|
||||
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('clicking schedule nav link navigates to schedule page', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
|
||||
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||
await navLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/schedule/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,9 @@ export default tseslint.config(
|
||||
clearTimeout: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
File: 'readonly',
|
||||
Blob: 'readonly'
|
||||
Blob: 'readonly',
|
||||
HTMLButtonElement: 'readonly',
|
||||
MouseEvent: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { DemoData } from '$types';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
import { getActiveRole } from '$features/roles/roleContext.svelte';
|
||||
|
||||
let {
|
||||
demoData,
|
||||
@@ -14,6 +19,47 @@
|
||||
hasRealData?: boolean;
|
||||
isMinor?: boolean;
|
||||
} = $props();
|
||||
|
||||
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
|
||||
|
||||
// Schedule widget state (AC1: "0 tap" — visible dès le dashboard)
|
||||
let scheduleSlots = $state<ScheduleSlot[]>([]);
|
||||
let scheduleNextSlotId = $state<string | null>(null);
|
||||
let scheduleLoading = $state(false);
|
||||
let scheduleError = $state<string | null>(null);
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
async function loadTodaySchedule() {
|
||||
scheduleLoading = true;
|
||||
scheduleError = null;
|
||||
|
||||
try {
|
||||
const today = formatLocalDate(new Date());
|
||||
scheduleSlots = await fetchDaySchedule(today);
|
||||
recordSync();
|
||||
|
||||
try {
|
||||
const next = await fetchNextClass();
|
||||
scheduleNextSlotId = next?.slotId ?? null;
|
||||
} catch {
|
||||
scheduleNextSlotId = null;
|
||||
}
|
||||
} catch (e) {
|
||||
scheduleError = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
scheduleLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isEleve) {
|
||||
loadTodaySchedule();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard-student">
|
||||
@@ -45,11 +91,18 @@
|
||||
<!-- EDT Section -->
|
||||
<DashboardSection
|
||||
title="Mon emploi du temps"
|
||||
subtitle={hasRealData ? "Aujourd'hui" : undefined}
|
||||
isPlaceholder={!hasRealData}
|
||||
subtitle={isEleve ? "Aujourd'hui" : (hasRealData ? "Aujourd'hui" : undefined)}
|
||||
isPlaceholder={!isEleve && !hasRealData}
|
||||
placeholderMessage={isMinor ? "Ton emploi du temps sera bientôt disponible" : "Votre emploi du temps sera bientôt disponible"}
|
||||
>
|
||||
{#if hasRealData}
|
||||
{#if isEleve}
|
||||
<ScheduleWidget
|
||||
slots={scheduleSlots}
|
||||
nextSlotId={scheduleNextSlotId}
|
||||
isLoading={scheduleLoading}
|
||||
error={scheduleError}
|
||||
/>
|
||||
{:else if hasRealData}
|
||||
{#if isLoading}
|
||||
<SkeletonList items={4} message="Chargement de l'emploi du temps..." />
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
let {
|
||||
slots = [],
|
||||
date,
|
||||
nextSlotId = null,
|
||||
onSlotClick
|
||||
}: {
|
||||
slots: ScheduleSlot[];
|
||||
date: string;
|
||||
nextSlotId: string | null;
|
||||
onSlotClick: (slot: ScheduleSlot) => void;
|
||||
} = $props();
|
||||
|
||||
let dayLabel = $derived(
|
||||
new Date(date + 'T00:00:00').toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
);
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
let isToday = $derived(date === formatLocalDate(new Date()));
|
||||
</script>
|
||||
|
||||
<div class="day-view">
|
||||
<h2 class="day-title" class:today={isToday}>
|
||||
{dayLabel}
|
||||
{#if isToday}
|
||||
<span class="today-badge">Aujourd'hui</span>
|
||||
{/if}
|
||||
</h2>
|
||||
|
||||
{#if slots.length === 0}
|
||||
<div class="no-courses">Aucun cours ce jour</div>
|
||||
{:else}
|
||||
<ul class="slot-list">
|
||||
{#each slots as slot (slot.slotId + slot.date)}
|
||||
<li class="slot-item" class:next={slot.slotId === nextSlotId}>
|
||||
<button
|
||||
class="slot-button"
|
||||
onclick={() => onSlotClick(slot)}
|
||||
data-testid="schedule-slot"
|
||||
>
|
||||
<div class="slot-time">
|
||||
<span class="time-start">{slot.startTime}</span>
|
||||
<span class="time-separator">-</span>
|
||||
<span class="time-end">{slot.endTime}</span>
|
||||
</div>
|
||||
<div class="slot-content">
|
||||
<span class="slot-subject">{slot.subjectName}</span>
|
||||
<span class="slot-meta">
|
||||
{slot.teacherName}
|
||||
{#if slot.room} · {slot.room}{/if}
|
||||
</span>
|
||||
</div>
|
||||
{#if slot.slotId === nextSlotId}
|
||||
<span class="next-badge">Prochain</span>
|
||||
{/if}
|
||||
{#if slot.isModified}
|
||||
<span class="modified-badge">Modifié</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.day-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.day-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin: 0;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.day-title.today {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.today-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border-radius: 1rem;
|
||||
font-weight: 500;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.no-courses {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slot-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-item {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
border-left: 4px solid #e5e7eb;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.slot-item.next {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.slot-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f9fafb;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.slot-button:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.slot-item.next .slot-button {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 3.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.time-start {
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.time-separator {
|
||||
font-size: 0.625rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.time-end {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.slot-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slot-subject {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.slot-meta {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.next-badge,
|
||||
.modified-badge {
|
||||
position: absolute;
|
||||
top: 0.375rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.next-badge {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modified-badge {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
top: auto;
|
||||
bottom: 0.375rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||
|
||||
let {
|
||||
slots = [],
|
||||
nextSlotId = null,
|
||||
isLoading = false,
|
||||
error = null
|
||||
}: {
|
||||
slots: ScheduleSlot[];
|
||||
nextSlotId: string | null;
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
} = $props();
|
||||
|
||||
let offline = $state(isOffline());
|
||||
let lastSync = $derived(getLastSyncDate());
|
||||
|
||||
$effect(() => {
|
||||
function handleOnline() {
|
||||
offline = false;
|
||||
}
|
||||
function handleOffline() {
|
||||
offline = true;
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
|
||||
function formatSyncDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="schedule-widget">
|
||||
{#if offline}
|
||||
<div class="offline-banner" role="status">
|
||||
<span class="offline-dot"></span>
|
||||
<span>Hors ligne</span>
|
||||
{#if lastSync}
|
||||
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">Chargement...</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if slots.length === 0}
|
||||
<div class="empty">Aucun cours aujourd'hui</div>
|
||||
{:else}
|
||||
<ul class="slot-list">
|
||||
{#each slots as slot (slot.slotId + slot.date)}
|
||||
<li
|
||||
class="slot-item"
|
||||
class:next={slot.slotId === nextSlotId}
|
||||
data-testid="schedule-slot"
|
||||
>
|
||||
<div class="slot-time">
|
||||
<span class="time-start">{slot.startTime}</span>
|
||||
<span class="time-end">{slot.endTime}</span>
|
||||
</div>
|
||||
<div class="slot-content">
|
||||
<span class="slot-subject">{slot.subjectName}</span>
|
||||
<span class="slot-teacher">{slot.teacherName}</span>
|
||||
</div>
|
||||
{#if slot.room}
|
||||
<span class="slot-room">{slot.room}</span>
|
||||
{/if}
|
||||
{#if slot.slotId === nextSlotId}
|
||||
<span class="next-badge">Prochain</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.schedule-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-date {
|
||||
margin-left: auto;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.slot-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slot-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
align-items: center;
|
||||
border-left: 3px solid transparent;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slot-item.next {
|
||||
border-left-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.time-start {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.time-end {
|
||||
font-size: 0.75rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.slot-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.slot-subject {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.slot-teacher {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.slot-room {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.next-badge {
|
||||
position: absolute;
|
||||
top: 0.25rem;
|
||||
right: 0.5rem;
|
||||
font-size: 0.625rem;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 0.25rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,185 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
let {
|
||||
slot,
|
||||
onClose
|
||||
}: {
|
||||
slot: ScheduleSlot;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
let dialogRef = $state<HTMLDivElement | null>(null);
|
||||
let closeButtonRef = $state<HTMLButtonElement | null>(null);
|
||||
|
||||
let dayLabel = $derived(
|
||||
new Date(slot.date + 'T00:00:00').toLocaleDateString('fr-FR', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
})
|
||||
);
|
||||
|
||||
// Focus the close button on mount for accessibility
|
||||
$effect(() => {
|
||||
closeButtonRef?.focus();
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
// Focus trap: keep focus inside the dialog
|
||||
if (event.key === 'Tab' && dialogRef) {
|
||||
const focusable = dialogRef.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0]!;
|
||||
const last = focusable[focusable.length - 1]!;
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="overlay" onclick={handleOverlayClick} role="presentation">
|
||||
<div
|
||||
bind:this={dialogRef}
|
||||
class="details-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Détails du cours"
|
||||
>
|
||||
<button
|
||||
bind:this={closeButtonRef}
|
||||
class="close-button"
|
||||
onclick={onClose}
|
||||
aria-label="Fermer">×</button
|
||||
>
|
||||
|
||||
<h2 class="subject-name">{slot.subjectName}</h2>
|
||||
|
||||
<div class="detail-grid">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Horaire</span>
|
||||
<span class="detail-value">{slot.startTime} - {slot.endTime}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Date</span>
|
||||
<span class="detail-value" style="text-transform: capitalize">{dayLabel}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Enseignant</span>
|
||||
<span class="detail-value">{slot.teacherName}</span>
|
||||
</div>
|
||||
{#if slot.room}
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">Salle</span>
|
||||
<span class="detail-value">{slot.room}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if slot.isModified}
|
||||
<div class="modified-notice">
|
||||
Ce cours a été modifié par rapport à l'emploi du temps habituel.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.details-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
max-width: 24rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
right: 0.75rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: #9ca3af;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.25rem 0;
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
font-size: 0.875rem;
|
||||
color: #1f2937;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.modified-notice {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: #fefce8;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,367 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||
import { untrack } from 'svelte';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import DayView from './DayView.svelte';
|
||||
import WeekView from './WeekView.svelte';
|
||||
import SlotDetails from './SlotDetails.svelte';
|
||||
|
||||
type ViewMode = 'day' | 'week';
|
||||
|
||||
let viewMode = $state<ViewMode>('day');
|
||||
let currentDate = $state(todayStr());
|
||||
let slots = $state<ScheduleSlot[]>([]);
|
||||
let nextSlotId = $state<string | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedSlot = $state<ScheduleSlot | null>(null);
|
||||
let offline = $state(isOffline());
|
||||
let lastSync = $derived(getLastSyncDate());
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return formatLocalDate(new Date());
|
||||
}
|
||||
|
||||
function mondayOfWeek(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return formatLocalDate(d);
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (viewMode === 'day') {
|
||||
slots = await fetchDaySchedule(currentDate);
|
||||
} else {
|
||||
slots = await fetchWeekSchedule(currentDate);
|
||||
}
|
||||
recordSync();
|
||||
|
||||
// Load next class info
|
||||
try {
|
||||
const next = await fetchNextClass();
|
||||
nextSlotId = next?.slotId ?? null;
|
||||
} catch {
|
||||
nextSlotId = null;
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load (in $effect to avoid SSR) + online/offline listener + background prefetch
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
loadSchedule();
|
||||
// Prefetch next 30 days in background for offline support (AC5)
|
||||
prefetchScheduleDays(fetchDaySchedule);
|
||||
});
|
||||
|
||||
function handleOnline() {
|
||||
offline = false;
|
||||
loadSchedule();
|
||||
}
|
||||
function handleOffline() {
|
||||
offline = true;
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
|
||||
function navigateDay(offset: number) {
|
||||
const d = new Date(currentDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + offset);
|
||||
currentDate = formatLocalDate(d);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function navigateWeek(offset: number) {
|
||||
const d = new Date(currentDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + offset * 7);
|
||||
currentDate = formatLocalDate(d);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
currentDate = todayStr();
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode = mode;
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
// Swipe detection
|
||||
let touchStartX = $state(0);
|
||||
|
||||
function handleTouchStart(e: globalThis.TouchEvent) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) touchStartX = touch.clientX;
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: globalThis.TouchEvent) {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const touchEndX = touch.clientX;
|
||||
const diff = touchStartX - touchEndX;
|
||||
const threshold = 50;
|
||||
|
||||
if (Math.abs(diff) > threshold) {
|
||||
if (viewMode === 'day') {
|
||||
navigateDay(diff > 0 ? 1 : -1);
|
||||
} else {
|
||||
navigateWeek(diff > 0 ? 1 : -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatSyncDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="student-schedule">
|
||||
<!-- Header bar -->
|
||||
<div class="schedule-header">
|
||||
<div class="view-toggle">
|
||||
<button class:active={viewMode === 'day'} aria-pressed={viewMode === 'day'} onclick={() => setViewMode('day')}>Jour</button>
|
||||
<button class:active={viewMode === 'week'} aria-pressed={viewMode === 'week'} onclick={() => setViewMode('week')}>Semaine</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-controls">
|
||||
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(-1) : navigateWeek(-1)} aria-label="Précédent">‹</button>
|
||||
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
|
||||
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline indicator -->
|
||||
{#if offline}
|
||||
<div class="offline-banner" role="status">
|
||||
<span class="offline-dot"></span>
|
||||
<span>Hors ligne</span>
|
||||
{#if lastSync}
|
||||
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content area with swipe -->
|
||||
<div
|
||||
class="schedule-content"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
role="region"
|
||||
aria-label="Emploi du temps"
|
||||
>
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Chargement...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>{error}</p>
|
||||
<button onclick={loadSchedule}>Réessayer</button>
|
||||
</div>
|
||||
{:else if viewMode === 'day'}
|
||||
<DayView
|
||||
{slots}
|
||||
date={currentDate}
|
||||
{nextSlotId}
|
||||
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||
/>
|
||||
{:else}
|
||||
<WeekView
|
||||
{slots}
|
||||
weekStart={mondayOfWeek(currentDate)}
|
||||
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot detail modal (AC4) -->
|
||||
{#if selectedSlot}
|
||||
<SlotDetails slot={selectedSlot} onClose={() => (selectedSlot = null)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.student-schedule {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
color: #374151;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-date {
|
||||
margin-left: auto;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.schedule-content {
|
||||
min-height: 200px;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
let {
|
||||
slots = [],
|
||||
weekStart,
|
||||
onSlotClick
|
||||
}: {
|
||||
slots: ScheduleSlot[];
|
||||
weekStart: string;
|
||||
onSlotClick: (slot: ScheduleSlot) => void;
|
||||
} = $props();
|
||||
|
||||
const DAY_LABELS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven'];
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
let weekDays = $derived(
|
||||
DAY_LABELS.map((label, i) => {
|
||||
const d = new Date(weekStart + 'T00:00:00');
|
||||
d.setDate(d.getDate() + i);
|
||||
const dateStr = formatLocalDate(d);
|
||||
return {
|
||||
label,
|
||||
date: dateStr,
|
||||
dayNum: d.getDate(),
|
||||
isToday: dateStr === formatLocalDate(new Date())
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
let slotsByDay = $derived(
|
||||
weekDays.map((day) => ({
|
||||
...day,
|
||||
slots: slots
|
||||
.filter((s) => s.date === day.date)
|
||||
.sort((a, b) => a.startTime.localeCompare(b.startTime))
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="week-view">
|
||||
<!-- Mobile: liste verticale par jour -->
|
||||
<div class="week-list">
|
||||
{#each slotsByDay as day (day.date)}
|
||||
<div class="day-section" class:today={day.isToday}>
|
||||
<div class="day-header-mobile" class:today={day.isToday}>
|
||||
<span class="day-label">{day.label} {day.dayNum}</span>
|
||||
<span class="day-count">{day.slots.length} cours</span>
|
||||
</div>
|
||||
|
||||
{#if day.slots.length === 0}
|
||||
<div class="empty-day">Aucun cours</div>
|
||||
{:else}
|
||||
<div class="day-slots-mobile">
|
||||
{#each day.slots as slot (slot.slotId + slot.date)}
|
||||
<button
|
||||
class="week-slot-mobile"
|
||||
class:modified={slot.isModified}
|
||||
onclick={() => onSlotClick(slot)}
|
||||
data-testid="week-slot"
|
||||
>
|
||||
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
|
||||
<span class="slot-subject-mobile">{slot.subjectName}</span>
|
||||
{#if slot.room}
|
||||
<span class="slot-room-mobile">{slot.room}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Desktop: grille 5 colonnes -->
|
||||
<div class="week-grid">
|
||||
{#each slotsByDay as day (day.date)}
|
||||
<div class="day-column">
|
||||
<div class="day-header-desktop" class:today={day.isToday}>
|
||||
<span class="day-label">{day.label}</span>
|
||||
<span class="day-num">{day.dayNum}</span>
|
||||
</div>
|
||||
|
||||
<div class="day-slots">
|
||||
{#if day.slots.length === 0}
|
||||
<div class="empty-day-desktop">-</div>
|
||||
{:else}
|
||||
{#each day.slots as slot (slot.slotId + slot.date)}
|
||||
<button
|
||||
class="week-slot-desktop"
|
||||
class:modified={slot.isModified}
|
||||
onclick={() => onSlotClick(slot)}
|
||||
data-testid="week-slot"
|
||||
>
|
||||
<span class="week-slot-time">{slot.startTime}</span>
|
||||
<span class="week-slot-subject">{slot.subjectName}</span>
|
||||
{#if slot.room}
|
||||
<span class="week-slot-room">{slot.room}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* ── Layout ── */
|
||||
.week-view {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* Mobile first: list visible, grid hidden */
|
||||
.week-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Mobile: day sections ── */
|
||||
.day-section {
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.day-section.today {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.day-header-mobile {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: #f3f4f6;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.day-header-mobile.today {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-count {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.empty-day {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.day-slots-mobile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.week-slot-mobile {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border: none;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
width: 100%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.week-slot-mobile:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.week-slot-mobile.modified {
|
||||
background: #fefce8;
|
||||
}
|
||||
|
||||
.slot-time-mobile {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
white-space: nowrap;
|
||||
min-width: 6.5rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.slot-subject-mobile {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slot-room-mobile {
|
||||
color: #6b7280;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Desktop: 5-column grid ── */
|
||||
@media (min-width: 768px) {
|
||||
.week-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.week-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.day-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.day-header-desktop {
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.day-header-desktop.today {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.day-num {
|
||||
display: block;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.day-slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.empty-day-desktop {
|
||||
text-align: center;
|
||||
color: #d1d5db;
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.week-slot-desktop {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.5rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.week-slot-desktop:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.week-slot-desktop.modified {
|
||||
border-color: #fcd34d;
|
||||
background: #fefce8;
|
||||
}
|
||||
|
||||
.week-slot-time {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.week-slot-subject {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.week-slot-room {
|
||||
color: #6b7280;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal file
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
export interface ScheduleSlot {
|
||||
slotId: string;
|
||||
date: string;
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
subjectId: string;
|
||||
subjectName: string;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
room: string | null;
|
||||
isModified: boolean;
|
||||
exceptionId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'EDT du jour pour l'élève connecté.
|
||||
*/
|
||||
export async function fetchDaySchedule(date: string): Promise<ScheduleSlot[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/schedule/day/${date}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère l'EDT de la semaine pour l'élève connecté.
|
||||
*/
|
||||
export async function fetchWeekSchedule(date: string): Promise<ScheduleSlot[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/schedule/week/${date}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement de l'EDT (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le prochain cours pour l'élève connecté.
|
||||
*/
|
||||
export async function fetchNextClass(): Promise<ScheduleSlot | null> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/schedule/next-class`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement du prochain cours (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data ?? null;
|
||||
}
|
||||
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal file
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
|
||||
|
||||
/**
|
||||
* Vérifie si le navigateur est actuellement hors ligne.
|
||||
*/
|
||||
export function isOffline(): boolean {
|
||||
if (!browser) return false;
|
||||
return !navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre la date de dernière synchronisation de l'EDT.
|
||||
*/
|
||||
export function recordSync(): void {
|
||||
if (!browser) return;
|
||||
localStorage.setItem(LAST_SYNC_KEY, new Date().toISOString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère la date de dernière synchronisation de l'EDT.
|
||||
*/
|
||||
export function getLastSyncDate(): string | null {
|
||||
if (!browser) return null;
|
||||
return localStorage.getItem(LAST_SYNC_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pré-charge 30 jours d'EDT en cache Service Worker (7 passés + 23 futurs).
|
||||
*
|
||||
* Appelé en arrière-plan pour alimenter le cache offline (AC5).
|
||||
* Les requêtes sont interceptées par le SW (NetworkFirst)
|
||||
* et les réponses sont automatiquement mises en cache.
|
||||
*/
|
||||
export async function prefetchScheduleDays(
|
||||
fetchFn: (date: string) => Promise<unknown>,
|
||||
today: Date = new Date()
|
||||
): Promise<void> {
|
||||
const CONCURRENCY = 5;
|
||||
const PAST_DAYS = 7;
|
||||
const FUTURE_DAYS = 23;
|
||||
const dates: string[] = [];
|
||||
|
||||
for (let i = -PAST_DAYS; i <= FUTURE_DAYS; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() + i);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
dates.push(`${y}-${m}-${d}`);
|
||||
}
|
||||
|
||||
for (let i = 0; i < dates.length; i += CONCURRENCY) {
|
||||
const batch = dates.slice(i, i + CONCURRENCY);
|
||||
await Promise.allSettled(batch.map((dateStr) => fetchFn(dateStr)));
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
|
||||
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
|
||||
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
|
||||
import { fetchRoles, resetRoleContext, getActiveRole } from '$features/roles/roleContext.svelte';
|
||||
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
let isLoggingOut = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
let logoUrl = $derived(getLogoUrl());
|
||||
let pathname = $derived(page.url.pathname);
|
||||
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
|
||||
|
||||
// Load user roles on mount for multi-role context switching (FR5)
|
||||
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
|
||||
@@ -23,6 +27,20 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Close menu on route change
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
mobileMenuOpen = false;
|
||||
});
|
||||
|
||||
// Lock body scroll when mobile menu is open
|
||||
$effect(() => {
|
||||
document.body.style.overflow = mobileMenuOpen ? 'hidden' : '';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
});
|
||||
|
||||
async function handleLogout() {
|
||||
isLoggingOut = true;
|
||||
try {
|
||||
@@ -41,8 +59,24 @@
|
||||
function goSettings() {
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && mobileMenuOpen) {
|
||||
closeMobileMenu();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dashboard-layout">
|
||||
<header class="dashboard-header">
|
||||
<div class="header-content">
|
||||
@@ -52,22 +86,84 @@
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
</button>
|
||||
<nav class="header-nav">
|
||||
|
||||
<button
|
||||
class="hamburger-button"
|
||||
onclick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Ouvrir le menu de navigation"
|
||||
>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<nav class="desktop-nav">
|
||||
<RoleSwitcher />
|
||||
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
|
||||
<button class="nav-button" onclick={goSettings}>Parametres</button>
|
||||
<a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a>
|
||||
{#if isEleve}
|
||||
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
|
||||
{/if}
|
||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||
{#if isLoggingOut}
|
||||
<span class="spinner"></span>
|
||||
Deconnexion...
|
||||
Déconnexion...
|
||||
{:else}
|
||||
Deconnexion
|
||||
Déconnexion
|
||||
{/if}
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="mobile-overlay"
|
||||
onclick={closeMobileMenu}
|
||||
onkeydown={(e) => e.key === 'Enter' && closeMobileMenu()}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div class="mobile-drawer" role="dialog" aria-modal="true" aria-label="Menu de navigation">
|
||||
<div class="mobile-drawer-header">
|
||||
{#if logoUrl}
|
||||
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
|
||||
{/if}
|
||||
<span class="logo-text">Classeo</span>
|
||||
<button class="mobile-close" onclick={closeMobileMenu} aria-label="Fermer le menu">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-drawer-body">
|
||||
<div class="mobile-role-switcher">
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
<a href="/dashboard" class="mobile-nav-link" class:active={pathname === '/dashboard'}>
|
||||
Tableau de bord
|
||||
</a>
|
||||
{#if isEleve}
|
||||
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
|
||||
Mon emploi du temps
|
||||
</a>
|
||||
{/if}
|
||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||
</div>
|
||||
<div class="mobile-drawer-footer">
|
||||
<button
|
||||
class="mobile-nav-link mobile-logout"
|
||||
onclick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{#if isLoggingOut}
|
||||
Déconnexion...
|
||||
{:else}
|
||||
Déconnexion
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="dashboard-main">
|
||||
<div class="main-content">
|
||||
{@render children()}
|
||||
@@ -86,7 +182,7 @@
|
||||
.dashboard-header {
|
||||
background: var(--surface-elevated, #fff);
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
padding: 0 1.5rem;
|
||||
padding: 0 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
@@ -98,10 +194,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.75rem 0;
|
||||
gap: 0.75rem;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.logo-button {
|
||||
@@ -127,12 +220,38 @@
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
/* Hamburger — visible on mobile */
|
||||
.hamburger-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.hamburger-button:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--text-secondary, #64748b);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Desktop nav — hidden on mobile */
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -144,6 +263,7 @@
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -166,6 +286,7 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
@@ -186,6 +307,7 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logout-button:hover:not(:disabled) {
|
||||
@@ -198,6 +320,108 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Mobile drawer */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(300px, 85vw);
|
||||
background: var(--surface-elevated, #fff);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideInLeft 0.25s ease-out;
|
||||
}
|
||||
|
||||
.mobile-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
}
|
||||
|
||||
.mobile-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-close:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.mobile-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.mobile-role-switcher {
|
||||
padding: 0.5rem 1.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mobile-nav-link:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.mobile-nav-link.active {
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
border-left-color: var(--accent-primary, #0ea5e9);
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
}
|
||||
|
||||
.mobile-logout {
|
||||
color: var(--color-alert, #ef4444);
|
||||
}
|
||||
|
||||
.mobile-logout:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.mobile-drawer-footer {
|
||||
border-top: 1px solid var(--border-subtle, #e2e8f0);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -223,19 +447,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.header-content {
|
||||
flex-wrap: nowrap;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
gap: 1rem;
|
||||
justify-content: flex-start;
|
||||
.hamburger-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mobile-overlay,
|
||||
.mobile-drawer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-main {
|
||||
|
||||
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal file
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import StudentSchedule from '$lib/components/organisms/StudentSchedule/StudentSchedule.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mon emploi du temps - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="schedule-page">
|
||||
<header class="page-header">
|
||||
<h1>Mon emploi du temps</h1>
|
||||
</header>
|
||||
<StudentSchedule />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.schedule-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock $app/environment
|
||||
vi.mock('$app/environment', () => ({
|
||||
browser: true
|
||||
}));
|
||||
|
||||
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
|
||||
describe('scheduleCache', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('isOffline', () => {
|
||||
it('returns false when navigator is online', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
|
||||
expect(isOffline()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when navigator is offline', () => {
|
||||
Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });
|
||||
expect(isOffline()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync tracking', () => {
|
||||
it('returns null when no sync has been recorded', () => {
|
||||
expect(getLastSyncDate()).toBeNull();
|
||||
});
|
||||
|
||||
it('records and retrieves the last sync date', () => {
|
||||
recordSync();
|
||||
const date = getLastSyncDate();
|
||||
expect(date).not.toBeNull();
|
||||
expect(new Date(date!).getTime()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prefetchScheduleDays', () => {
|
||||
it('prefetches 7 past days + today + 23 future days (31 total)', async () => {
|
||||
const fetchFn = vi.fn().mockResolvedValue({});
|
||||
const today = new Date('2026-03-10');
|
||||
|
||||
await prefetchScheduleDays(fetchFn, today);
|
||||
|
||||
// 7 past + 1 today + 23 future = 31 calls
|
||||
expect(fetchFn).toHaveBeenCalledTimes(31);
|
||||
// First call: 7 days ago
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-03-03');
|
||||
// Today
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-03-10');
|
||||
// Last call: 23 days ahead
|
||||
expect(fetchFn).toHaveBeenCalledWith('2026-04-02');
|
||||
});
|
||||
|
||||
it('silently handles fetch failures', async () => {
|
||||
const fetchFn = vi.fn()
|
||||
.mockResolvedValueOnce({})
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValue({});
|
||||
|
||||
await expect(
|
||||
prefetchScheduleDays(fetchFn, new Date('2026-03-02'))
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchFn).toHaveBeenCalledTimes(31);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
|
||||
vi.mock('$lib/features/schedule/stores/scheduleCache', () => ({
|
||||
isOffline: vi.fn(() => false),
|
||||
getLastSyncDate: vi.fn(() => null)
|
||||
}));
|
||||
|
||||
function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
|
||||
return {
|
||||
slotId: 'slot-1',
|
||||
date: '2026-03-05',
|
||||
dayOfWeek: 4,
|
||||
startTime: '08:00',
|
||||
endTime: '09:00',
|
||||
subjectId: 'sub-1',
|
||||
subjectName: 'Mathématiques',
|
||||
teacherId: 'teacher-1',
|
||||
teacherName: 'M. Dupont',
|
||||
room: 'Salle 101',
|
||||
isModified: false,
|
||||
exceptionId: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('ScheduleWidget', () => {
|
||||
it('renders slots with subject, teacher, time and room', () => {
|
||||
const slot = makeSlot();
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [slot], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Mathématiques')).toBeTruthy();
|
||||
expect(screen.getByText('M. Dupont')).toBeTruthy();
|
||||
expect(screen.getByText('08:00')).toBeTruthy();
|
||||
expect(screen.getByText('09:00')).toBeTruthy();
|
||||
expect(screen.getByText('Salle 101')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows empty message when no slots', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.getByText("Aucun cours aujourd'hui")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows loading state', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null, isLoading: true }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Chargement...')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('shows error message', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [], nextSlotId: null, error: 'Erreur réseau' }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Erreur réseau')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('highlights next slot with "Prochain" badge', () => {
|
||||
const slots = [
|
||||
makeSlot({ slotId: 'slot-1', startTime: '08:00', endTime: '09:00' }),
|
||||
makeSlot({
|
||||
slotId: 'slot-2',
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
subjectName: 'Français',
|
||||
teacherName: 'Mme Martin'
|
||||
})
|
||||
];
|
||||
|
||||
render(ScheduleWidget, {
|
||||
props: { slots, nextSlotId: 'slot-2' }
|
||||
});
|
||||
|
||||
expect(screen.getByText('Prochain')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('does not show "Prochain" badge when nextSlotId is null', () => {
|
||||
render(ScheduleWidget, {
|
||||
props: { slots: [makeSlot()], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Prochain')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not render room when room is null', () => {
|
||||
const slot = makeSlot({ room: null });
|
||||
const { container } = render(ScheduleWidget, {
|
||||
props: { slots: [slot], nextSlotId: null }
|
||||
});
|
||||
|
||||
expect(container.querySelector('.slot-room')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders multiple slots with data-testid', () => {
|
||||
const slots = [
|
||||
makeSlot({ slotId: 'slot-1' }),
|
||||
makeSlot({ slotId: 'slot-2', subjectName: 'Français' }),
|
||||
makeSlot({ slotId: 'slot-3', subjectName: 'Histoire' })
|
||||
];
|
||||
|
||||
const { container } = render(ScheduleWidget, {
|
||||
props: { slots, nextSlotId: null }
|
||||
});
|
||||
|
||||
const items = container.querySelectorAll('[data-testid="schedule-slot"]');
|
||||
expect(items.length).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export default defineConfig({
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
categories: ['education'],
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.png',
|
||||
@@ -36,10 +37,35 @@ export default defineConfig({
|
||||
type: 'image/png',
|
||||
purpose: 'any maskable'
|
||||
}
|
||||
],
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'Mon emploi du temps',
|
||||
short_name: 'EDT',
|
||||
url: '/dashboard/schedule',
|
||||
description: 'Consulter mon emploi du temps'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}']
|
||||
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\/me\/schedule\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'schedule-v1',
|
||||
expiration: {
|
||||
maxEntries: 90,
|
||||
maxAgeSeconds: 30 * 24 * 60 * 60
|
||||
},
|
||||
networkTimeoutSeconds: 5,
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
|
||||
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
1
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
generated
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":"4.0.18","results":[[":frontend/tests/unit/lib/components/molecules/SearchInput/SearchInput.test.ts",{"duration":0,"failed":true}]]}
|
||||
Reference in New Issue
Block a user