Compare commits

..

2 Commits

Author SHA1 Message Date
81e97c4f3b feat: Afficher la couleur des matières dans l'emploi du temps élève et parent
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
L'admin pouvait attribuer une couleur à chaque matière, mais cette
couleur n'était utilisée que dans la vue admin de l'emploi du temps.
Les APIs élève et parent ne renvoyaient pas cette information, ce qui
donnait un affichage générique (gris/bleu) pour tous les créneaux.

L'API renvoie désormais subjectColor dans chaque créneau, et les vues
jour/semaine/widget/détails affichent la bordure colorée correspondante.
Le marqueur "Prochain cours" conserve sa priorité visuelle via une
surcharge CSS variable.
2026-03-09 15:57:14 +01:00
bda63bd98c fix: Permettre l'affectation de classe pour les élèves sans affectation existante
Le handler ChangeStudentClass exigeait une affectation existante pour
l'année scolaire en cours avant de pouvoir changer la classe. Un élève
créé sans ClassAssignment (import direct, année précédente) provoquait
une erreur "Élève non trouvé" au lieu d'être simplement affecté.

Le handler crée désormais une nouvelle affectation quand aucune n'existe,
et l'erreur de changement de classe s'affiche dans la modale au lieu de
la page principale.
2026-03-09 11:20:29 +01:00
32 changed files with 223 additions and 107 deletions

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace App\Administration\Application\Command\ChangeStudentClass;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
@@ -44,15 +43,23 @@ final readonly class ChangeStudentClassHandler
throw ClasseNotFoundException::withId($newClassId);
}
// Trouver l'affectation existante
$now = $this->clock->now();
// Trouver l'affectation existante ou en créer une nouvelle
$assignment = $this->classAssignmentRepository->findByStudent($studentId, $academicYearId, $tenantId);
if ($assignment === null) {
throw AffectationEleveNonTrouveeException::pourEleve($studentId);
if ($assignment !== null) {
$assignment->changerClasse($newClassId, $now);
} else {
$assignment = ClassAssignment::affecter(
tenantId: $tenantId,
studentId: $studentId,
classId: $newClassId,
academicYearId: $academicYearId,
assignedAt: $now,
);
}
$assignment->changerClasse($newClassId, $this->clock->now());
$this->classAssignmentRepository->save($assignment);
return $assignment;

View File

@@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Administration\Domain\Exception;
use App\Administration\Domain\Model\User\UserId;
use DomainException;
use function sprintf;
final class AffectationEleveNonTrouveeException extends DomainException
{
public static function pourEleve(UserId $studentId): self
{
return new self(sprintf(
'Aucune affectation trouvée pour l\'élève "%s" cette année scolaire.',
$studentId,
));
}
}

View File

@@ -8,7 +8,6 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Repository\ClassRepository;
use App\Administration\Infrastructure\Api\Resource\StudentResource;
@@ -80,8 +79,6 @@ final readonly class ChangeStudentClassProcessor implements ProcessorInterface
$data->classLevel = $newClass->level?->value;
return $data;
} catch (AffectationEleveNonTrouveeException) {
throw new NotFoundHttpException('Élève non trouvé.');
} catch (ClasseNotFoundException $e) {
throw new NotFoundHttpException($e->getMessage());
}

View File

@@ -15,9 +15,9 @@ interface ScheduleDisplayReader
/**
* @param string ...$subjectIds Identifiants des matières
*
* @return array<string, string> Map subjectId => nom de la matière
* @return array<string, array{name: string, color: string|null}> Map subjectId => {name, color}
*/
public function subjectNames(string $tenantId, string ...$subjectIds): array;
public function subjectDisplay(string $tenantId, string ...$subjectIds): array;
/**
* @param string ...$teacherIds Identifiants des enseignants

View File

@@ -111,13 +111,14 @@ final readonly class GetChildrenScheduleHandler
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
));
$subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds);
$subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds);
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
return array_map(
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
$s,
$subjectNames[(string) $s->subjectId] ?? '',
$subjects[(string) $s->subjectId]['name'] ?? '',
$subjects[(string) $s->subjectId]['color'] ?? null,
$teacherNames[(string) $s->teacherId] ?? '',
),
$slots,

View File

@@ -74,13 +74,14 @@ final readonly class GetStudentScheduleHandler
array_map(static fn (ResolvedScheduleSlot $s): string => (string) $s->teacherId, $slots),
));
$subjectNames = $this->displayReader->subjectNames($tenantId, ...$subjectIds);
$subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds);
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
return array_map(
static fn (ResolvedScheduleSlot $s): StudentScheduleSlotDto => StudentScheduleSlotDto::fromResolved(
$s,
$subjectNames[(string) $s->subjectId] ?? '',
$subjects[(string) $s->subjectId]['name'] ?? '',
$subjects[(string) $s->subjectId]['color'] ?? null,
$teacherNames[(string) $s->teacherId] ?? '',
),
$slots,

View File

@@ -16,6 +16,7 @@ final readonly class StudentScheduleSlotDto
public string $endTime,
public string $subjectId,
public string $subjectName,
public ?string $subjectColor,
public string $teacherId,
public string $teacherName,
public ?string $room,
@@ -27,6 +28,7 @@ final readonly class StudentScheduleSlotDto
public static function fromResolved(
ResolvedScheduleSlot $slot,
string $subjectName,
?string $subjectColor,
string $teacherName,
): self {
return new self(
@@ -37,6 +39,7 @@ final readonly class StudentScheduleSlotDto
endTime: $slot->timeSlot->endTime,
subjectId: (string) $slot->subjectId,
subjectName: $subjectName,
subjectColor: $subjectColor,
teacherId: (string) $slot->teacherId,
teacherName: $teacherName,
room: $slot->room,

View File

@@ -29,9 +29,6 @@ use function usort;
/**
* Endpoints de consultation de l'emploi du temps des enfants pour le parent connecté.
*
* @see Story 4.4 - Consultation EDT par le Parent
* @see FR30 - Consulter emploi du temps enfant (parent)
*/
#[IsGranted(ScheduleSlotVoter::VIEW)]
final readonly class ParentScheduleController
@@ -190,6 +187,7 @@ final readonly class ParentScheduleController
'endTime' => $slot->endTime,
'subjectId' => $slot->subjectId,
'subjectName' => $slot->subjectName,
'subjectColor' => $slot->subjectColor,
'teacherId' => $slot->teacherId,
'teacherName' => $slot->teacherName,
'room' => $slot->room,

View File

@@ -29,9 +29,6 @@ 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
@@ -178,6 +175,7 @@ final readonly class StudentScheduleController
'endTime' => $slot->endTime,
'subjectId' => $slot->subjectId,
'subjectName' => $slot->subjectName,
'subjectColor' => $slot->subjectColor,
'teacherId' => $slot->teacherId,
'teacherName' => $slot->teacherName,
'room' => $slot->room,

View File

@@ -20,29 +20,31 @@ final readonly class DoctrineScheduleDisplayReader implements ScheduleDisplayRea
}
#[Override]
public function subjectNames(string $tenantId, string ...$subjectIds): array
public function subjectDisplay(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',
'SELECT id, name, color FROM subjects WHERE id IN (:ids) AND tenant_id = :tenantId',
['ids' => $subjectIds, 'tenantId' => $tenantId],
['ids' => ArrayParameterType::STRING],
);
$names = [];
$display = [];
foreach ($rows as $row) {
/** @var string $id */
$id = $row['id'];
/** @var string $name */
$name = $row['name'];
$names[$id] = $name;
/** @var string|null $color */
$color = $row['color'];
$display[$id] = ['name' => $name, 'color' => $color];
}
return $names;
return $display;
}
#[Override]

View File

@@ -6,7 +6,6 @@ namespace App\Tests\Unit\Administration\Application\Command\ChangeStudentClass;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassCommand;
use App\Administration\Application\Command\ChangeStudentClass\ChangeStudentClassHandler;
use App\Administration\Domain\Exception\AffectationEleveNonTrouveeException;
use App\Administration\Domain\Exception\ClasseNotFoundException;
use App\Administration\Domain\Model\ClassAssignment\ClassAssignment;
use App\Administration\Domain\Model\SchoolClass\AcademicYearId;
@@ -74,13 +73,17 @@ final class ChangeStudentClassHandlerTest extends TestCase
}
#[Test]
public function itThrowsWhenAssignmentNotFound(): void
public function itCreatesAssignmentWhenNoneExists(): void
{
$handler = $this->createHandler();
$command = $this->createCommand(studentId: '550e8400-e29b-41d4-a716-446655440070');
$newStudentId = '550e8400-e29b-41d4-a716-446655440070';
$command = $this->createCommand(studentId: $newStudentId);
$this->expectException(AffectationEleveNonTrouveeException::class);
$handler($command);
$assignment = $handler($command);
self::assertTrue($assignment->studentId->equals(UserId::fromString($newStudentId)));
self::assertTrue($assignment->classId->equals(ClassId::fromString(self::NEW_CLASS_ID)));
self::assertTrue($assignment->academicYearId->equals(AcademicYearId::fromString(self::ACADEMIC_YEAR_ID)));
}
#[Test]

View File

@@ -265,9 +265,14 @@ final class GetChildrenScheduleHandlerTest extends TestCase
) {
}
public function subjectNames(string $tenantId, string ...$subjectIds): array
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
{
return $this->subjects;
$display = [];
foreach ($this->subjects as $id => $name) {
$display[$id] = ['name' => $name, 'color' => null];
}
return $display;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array

View File

@@ -210,9 +210,14 @@ final class GetStudentScheduleHandlerTest extends TestCase
) {
}
public function subjectNames(string $tenantId, string ...$subjectIds): array
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
{
return $this->subjects;
$display = [];
foreach ($this->subjects as $id => $name) {
$display[$id] = ['name' => $name, 'color' => null];
}
return $display;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array

View File

@@ -147,11 +147,11 @@ test.describe('Child Selector', () => {
// Should display the label
await expect(childSelector.locator('.child-selector-label')).toHaveText('Enfant :');
// Should have 2 child buttons
// Should have 3 buttons: "Tous" + 2 children
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
await expect(buttons).toHaveCount(3);
// First child should be auto-selected
// "Tous" button should be selected initially (no child auto-selected)
await expect(buttons.first()).toHaveClass(/selected/);
});
@@ -162,18 +162,18 @@ test.describe('Child Selector', () => {
await expect(childSelector).toBeVisible({ timeout: 10000 });
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(2);
await expect(buttons).toHaveCount(3);
// First button should be selected initially
await expect(buttons.first()).toHaveClass(/selected/);
// "Tous" button (index 0) should be selected initially
await expect(buttons.nth(0)).toHaveClass(/selected/);
await expect(buttons.nth(1)).not.toHaveClass(/selected/);
// Click second button
// Click first child button (index 1)
await buttons.nth(1).click();
// Second button should now be selected, first should not
// First child should now be selected, "Tous" should not
await expect(buttons.nth(1)).toHaveClass(/selected/);
await expect(buttons.first()).not.toHaveClass(/selected/);
await expect(buttons.nth(0)).not.toHaveClass(/selected/);
});
test('[P1] parent with single child should see static child name', async ({ browser, page }) => {

View File

@@ -114,6 +114,21 @@ async function loginAsParent(page: import('@playwright/test').Page) {
]);
}
/**
* Multi-child parents land on the summary view (no child auto-selected).
* This helper selects the first actual child (skipping the "Tous" button).
*/
async function selectFirstChild(page: import('@playwright/test').Page) {
const childButtons = page.locator('.child-button');
await expect(childButtons.first()).toBeVisible({ timeout: 15000 });
const count = await childButtons.count();
if (count > 2) {
// Multi-child: "Tous" is at index 0, first child at index 1
await childButtons.nth(1).click();
}
// Single child is auto-selected, nothing to do
}
test.describe('Parent Schedule Consultation (Story 4.4)', () => {
test.describe.configure({ mode: 'serial' });
@@ -305,6 +320,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for slots to load
@@ -324,19 +340,29 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
// If multiple children, buttons should be visible
// Multi-child: "Tous" + N children buttons
const childButtons = page.locator('.child-button');
const count = await childButtons.count();
if (count > 1) {
// Click second child
if (count > 2) {
// Select first child (index 1, after "Tous")
await childButtons.nth(1).click();
await page.waitForTimeout(500);
// Should update schedule data
await navigateToSeededDay(page);
const slots = page.locator('[data-testid="schedule-slot"]');
await expect(slots.first()).toBeVisible({ timeout: 20000 });
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Switch to second child (index 2)
await childButtons.nth(2).click();
await page.waitForTimeout(500);
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
});
// Switch back to "Tous" summary view
await childButtons.nth(0).click();
await expect(page.locator('.multi-child-summary')).toBeVisible({ timeout: 10000 });
}
});
});
@@ -351,6 +377,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
@@ -362,6 +389,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for slots to load
@@ -384,6 +412,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
@@ -419,6 +448,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
await expect(
page.getByRole('heading', { name: /emploi du temps des enfants/i })
).toBeVisible({ timeout: 15000 });
await selectFirstChild(page);
// Wait for schedule slots to load
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
@@ -448,9 +478,11 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
const childButtons = page.locator('.child-button');
const count = await childButtons.count();
test.skip(count < 2, 'Need at least 2 children for this test');
// "Tous" + at least 2 children = 3 buttons minimum
test.skip(count < 3, 'Need at least 2 children for this test');
// Navigate to seeded day to see slots
// Select first child (index 1, after "Tous")
await childButtons.nth(1).click();
await navigateToSeededDay(page);
// First child (6A) should show Maths
@@ -461,8 +493,8 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
timeout: 5000
});
// Switch to second child (5B) — should show SVT
await childButtons.nth(1).click();
// Switch to second child (index 2) (5B) — should show SVT
await childButtons.nth(2).click();
await navigateToSeededDay(page);
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
timeout: 20000
@@ -480,6 +512,7 @@ test.describe('Parent Schedule Consultation (Story 4.4)', () => {
test('shows offline banner when network is lost', async ({ page, context }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-schedule`);
await selectFirstChild(page);
await navigateToSeededDay(page);
// Wait for schedule to load

View File

@@ -14,7 +14,7 @@
let {
onChildSelected
}: {
onChildSelected?: (childId: string) => void;
onChildSelected?: (childId: string | null) => void;
} = $props();
let children = $state<Child[]>([]);
@@ -40,8 +40,9 @@
const data = await response.json();
children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
// Auto-select only when there's exactly 1 child
const first = children[0];
if (first && !selectedChildId) {
if (first && !selectedChildId && children.length === 1) {
selectedChildId = first.studentId;
onChildSelected?.(first.studentId);
}
@@ -52,7 +53,7 @@
}
}
function selectChild(childId: string) {
function selectChild(childId: string | null) {
selectedChildId = childId;
onChildSelected?.(childId);
}
@@ -75,6 +76,13 @@
<div class="child-selector">
<span class="child-selector-label">Enfant :</span>
<div class="child-selector-buttons">
<button
class="child-button"
class:selected={selectedChildId === null}
onclick={() => selectChild(null)}
>
Tous
</button>
{#each children as child (child.id)}
<button
class="child-button"

View File

@@ -2,7 +2,7 @@
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';

View File

@@ -2,7 +2,7 @@
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 { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
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';

View File

@@ -2,7 +2,7 @@
import type { ChildScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
import { fetchChildrenScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
import { isOffline, getLastSyncDate, recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { untrack } from 'svelte';
let {
@@ -43,6 +43,7 @@
try {
children = await fetchChildrenScheduleSummary();
recordSync();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {

View File

@@ -2,7 +2,7 @@
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import { fetchChildDaySchedule, fetchChildWeekSchedule } from '$lib/features/schedule/api/parentSchedule';
import { untrack } from 'svelte';
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
@@ -88,14 +88,16 @@
};
});
function handleChildSelected(childId: string) {
function handleChildSelected(childId: string | null) {
selectedChildId = childId;
if (childId) {
untrack(() => {
loadSchedule();
// Prefetch for offline support
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
});
}
}
function navigateDay(offset: number) {
const d = new Date(currentDate + 'T00:00:00');

View File

@@ -44,7 +44,11 @@
{:else}
<ul class="slot-list">
{#each slots as slot (slot.slotId + slot.date)}
<li class="slot-item" class:next={slot.slotId === nextSlotId}>
<li
class="slot-item"
class:next={slot.slotId === nextSlotId}
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
>
<button
class="slot-button"
onclick={() => onSlotClick(slot)}
@@ -123,12 +127,12 @@
.slot-item {
border-radius: 0.75rem;
overflow: hidden;
border-left: 4px solid #e5e7eb;
border-left: 4px solid var(--slot-color, #e5e7eb);
transition: border-color 0.2s;
}
.slot-item.next {
border-left-color: #3b82f6;
--slot-color: #3b82f6;
}
.slot-button {

View File

@@ -1,7 +1,7 @@
<script lang="ts">
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache.svelte';
let {
slots = [],
@@ -61,6 +61,7 @@
class="slot-item"
class:next={slot.slotId === nextSlotId}
data-testid="schedule-slot"
style:--slot-color={slot.subjectColor ?? 'transparent'}
>
<div class="slot-time">
<span class="time-start">{slot.startTime}</span>
@@ -144,12 +145,12 @@
background: #f9fafb;
border-radius: 0.5rem;
align-items: center;
border-left: 3px solid transparent;
border-left: 3px solid var(--slot-color, transparent);
position: relative;
}
.slot-item.next {
border-left-color: #3b82f6;
--slot-color: #3b82f6;
background: #eff6ff;
}

View File

@@ -71,7 +71,7 @@
aria-label="Fermer">&times;</button
>
<h2 class="subject-name">{slot.subjectName}</h2>
<h2 class="subject-name" class:has-color={slot.subjectColor} style:--slot-color={slot.subjectColor}>{slot.subjectName}</h2>
<div class="detail-grid">
<div class="detail-row">
@@ -149,6 +149,11 @@
padding-right: 2rem;
}
.subject-name.has-color {
border-left: 4px solid var(--slot-color);
padding-left: 0.75rem;
}
.detail-grid {
display: flex;
flex-direction: column;

View File

@@ -3,7 +3,7 @@
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
import { untrack } from 'svelte';
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
import DayView from './DayView.svelte';
import WeekView from './WeekView.svelte';
import SlotDetails from './SlotDetails.svelte';

View File

@@ -64,6 +64,7 @@
class:modified={slot.isModified}
onclick={() => onSlotClick(slot)}
data-testid="week-slot"
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
>
<span class="slot-time-mobile">{slot.startTime} - {slot.endTime}</span>
<span class="slot-subject-mobile">{slot.subjectName}</span>
@@ -97,6 +98,7 @@
class:modified={slot.isModified}
onclick={() => onSlotClick(slot)}
data-testid="week-slot"
style:--slot-color={slot.subjectColor ?? '#e5e7eb'}
>
<span class="week-slot-time">{slot.startTime}</span>
<span class="week-slot-subject">{slot.subjectName}</span>
@@ -182,6 +184,7 @@
background: white;
border: none;
border-top: 1px solid #f3f4f6;
border-left: 4px solid var(--slot-color, #e5e7eb);
cursor: pointer;
text-align: left;
font-size: 0.875rem;
@@ -283,6 +286,7 @@
padding: 0.5rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-left: 4px solid var(--slot-color, #bfdbfe);
border-radius: 0.375rem;
cursor: pointer;
text-align: left;

View File

@@ -9,6 +9,7 @@ export interface ScheduleSlot {
endTime: string;
subjectId: string;
subjectName: string;
subjectColor: string | null;
teacherId: string;
teacherName: string;
room: string | null;

View File

@@ -2,6 +2,10 @@ import { browser } from '$app/environment';
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
let lastSyncValue = $state<string | null>(
browser ? localStorage.getItem(LAST_SYNC_KEY) : null
);
/**
* Vérifie si le navigateur est actuellement hors ligne.
*/
@@ -15,15 +19,16 @@ export function isOffline(): boolean {
*/
export function recordSync(): void {
if (!browser) return;
localStorage.setItem(LAST_SYNC_KEY, new Date().toISOString());
const now = new Date().toISOString();
localStorage.setItem(LAST_SYNC_KEY, now);
lastSyncValue = now;
}
/**
* Récupère la date de dernière synchronisation de l'EDT.
* Récupère la date de dernière synchronisation de l'EDT (réactif via $state).
*/
export function getLastSyncDate(): string | null {
if (!browser) return null;
return localStorage.getItem(LAST_SYNC_KEY);
return lastSyncValue;
}
/**

View File

@@ -62,6 +62,7 @@
let changeClassTarget = $state<Student | null>(null);
let newClassForChange = $state('');
let isChangingClass = $state(false);
let changeClassError = $state<string | null>(null);
// Classes grouped by level for optgroup
let classesByLevel = $derived.by(() => {
@@ -300,7 +301,7 @@
changeClassTarget = student;
newClassForChange = '';
showChangeClassModal = true;
error = null;
changeClassError = null;
}
function closeChangeClassModal() {
@@ -338,7 +339,7 @@
successMessage = `${changeClassTarget.firstName} ${changeClassTarget.lastName} a été transféré vers ${targetClass?.name ?? 'la nouvelle classe'}.`;
closeChangeClassModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors du changement de classe';
changeClassError = e instanceof Error ? e.message : 'Erreur lors du changement de classe';
} finally {
isChangingClass = false;
}
@@ -720,6 +721,13 @@
</select>
</div>
{#if changeClassError}
<div class="alert alert-error modal-error">
{changeClassError}
<button class="alert-close" onclick={() => (changeClassError = null)}>×</button>
</div>
{/if}
{#if newClassForChange}
{@const targetClass = classes.find((c) => c.id === newClassForChange)}
<div class="change-confirm-info">
@@ -1244,6 +1252,10 @@
justify-content: flex-end;
}
.modal-error {
margin-bottom: 0;
}
.change-confirm-info {
padding: 0.75rem 1rem;
background: #eff6ff;

View File

@@ -49,7 +49,7 @@
// Demo child name for personalized messages
let childName = $state('Emma');
function handleChildSelected(childId: string) {
function handleChildSelected(childId: string | null) {
selectedChildId = childId;
}

View File

@@ -5,7 +5,7 @@ vi.mock('$app/environment', () => ({
browser: true
}));
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache.svelte';
describe('scheduleCache', () => {
beforeEach(() => {

View File

@@ -3,7 +3,7 @@ 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', () => ({
vi.mock('$lib/features/schedule/stores/scheduleCache.svelte', () => ({
isOffline: vi.fn(() => false),
getLastSyncDate: vi.fn(() => null)
}));
@@ -17,6 +17,7 @@ function makeSlot(overrides: Partial<ScheduleSlot> = {}): ScheduleSlot {
endTime: '09:00',
subjectId: 'sub-1',
subjectName: 'Mathématiques',
subjectColor: null,
teacherId: 'teacher-1',
teacherName: 'M. Dupont',
room: 'Salle 101',
@@ -100,6 +101,16 @@ describe('ScheduleWidget', () => {
expect(container.querySelector('.slot-room')).toBeNull();
});
it('applies subject color as CSS variable on slot', () => {
const slot = makeSlot({ subjectColor: '#e74c3c' });
const { container } = render(ScheduleWidget, {
props: { slots: [slot], nextSlotId: null }
});
const item = container.querySelector('[data-testid="schedule-slot"]') as HTMLElement;
expect(item.style.getPropertyValue('--slot-color')).toBe('#e74c3c');
});
it('renders multiple slots with data-testid', () => {
const slots = [
makeSlot({ slotId: 'slot-1' }),

View File

@@ -79,6 +79,36 @@ export default defineConfig({
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/me\/children\/schedule\/summary/,
handler: 'NetworkFirst',
options: {
cacheName: 'parent-schedule-v1',
expiration: {
maxEntries: 10,
maxAgeSeconds: 30 * 24 * 60 * 60
},
networkTimeoutSeconds: 5,
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/me\/children$/,
handler: 'NetworkFirst',
options: {
cacheName: 'parent-children-v1',
expiration: {
maxEntries: 10,
maxAgeSeconds: 7 * 24 * 60 * 60
},
networkTimeoutSeconds: 5,
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
},