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.
368 lines
8.1 KiB
Svelte
368 lines
8.1 KiB
Svelte
<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>
|