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:
2026-03-05 16:21:37 +01:00
parent ae640e91ac
commit 36ceefb625
30 changed files with 3526 additions and 30 deletions

View File

@@ -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}

View File

@@ -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} &middot; {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>

View File

@@ -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>

View File

@@ -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">&times;</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>

View File

@@ -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">&lsaquo;</button>
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">&rsaquo;</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>

View File

@@ -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>

View 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;
}

View 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)));
}
}

View File

@@ -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">
&times;
</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 {

View 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>