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:
@@ -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>
|
||||
Reference in New Issue
Block a user