{@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 { diff --git a/frontend/src/routes/dashboard/schedule/+page.svelte b/frontend/src/routes/dashboard/schedule/+page.svelte new file mode 100644 index 0000000..1e74c95 --- /dev/null +++ b/frontend/src/routes/dashboard/schedule/+page.svelte @@ -0,0 +1,29 @@ + + + + Mon emploi du temps - Classeo + + +
+ + +
+ + diff --git a/frontend/tests/unit/features/schedule/scheduleCache.test.ts b/frontend/tests/unit/features/schedule/scheduleCache.test.ts new file mode 100644 index 0000000..a6a6aba --- /dev/null +++ b/frontend/tests/unit/features/schedule/scheduleCache.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock $app/environment +vi.mock('$app/environment', () => ({ + browser: true +})); + +import { isOffline, recordSync, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache'; + +describe('scheduleCache', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('isOffline', () => { + it('returns false when navigator is online', () => { + Object.defineProperty(navigator, 'onLine', { value: true, configurable: true }); + expect(isOffline()).toBe(false); + }); + + it('returns true when navigator is offline', () => { + Object.defineProperty(navigator, 'onLine', { value: false, configurable: true }); + expect(isOffline()).toBe(true); + }); + }); + + describe('sync tracking', () => { + it('returns null when no sync has been recorded', () => { + expect(getLastSyncDate()).toBeNull(); + }); + + it('records and retrieves the last sync date', () => { + recordSync(); + const date = getLastSyncDate(); + expect(date).not.toBeNull(); + expect(new Date(date!).getTime()).toBeGreaterThan(0); + }); + }); + + describe('prefetchScheduleDays', () => { + it('prefetches 7 past days + today + 23 future days (31 total)', async () => { + const fetchFn = vi.fn().mockResolvedValue({}); + const today = new Date('2026-03-10'); + + await prefetchScheduleDays(fetchFn, today); + + // 7 past + 1 today + 23 future = 31 calls + expect(fetchFn).toHaveBeenCalledTimes(31); + // First call: 7 days ago + expect(fetchFn).toHaveBeenCalledWith('2026-03-03'); + // Today + expect(fetchFn).toHaveBeenCalledWith('2026-03-10'); + // Last call: 23 days ahead + expect(fetchFn).toHaveBeenCalledWith('2026-04-02'); + }); + + it('silently handles fetch failures', async () => { + const fetchFn = vi.fn() + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValue({}); + + await expect( + prefetchScheduleDays(fetchFn, new Date('2026-03-02')) + ).resolves.toBeUndefined(); + + expect(fetchFn).toHaveBeenCalledTimes(31); + }); + }); +}); diff --git a/frontend/tests/unit/lib/components/organisms/StudentSchedule/ScheduleWidget.test.ts b/frontend/tests/unit/lib/components/organisms/StudentSchedule/ScheduleWidget.test.ts new file mode 100644 index 0000000..49ed26c --- /dev/null +++ b/frontend/tests/unit/lib/components/organisms/StudentSchedule/ScheduleWidget.test.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte'; +import type { ScheduleSlot } from '$lib/features/schedule/api/schedule'; + +vi.mock('$lib/features/schedule/stores/scheduleCache', () => ({ + isOffline: vi.fn(() => false), + getLastSyncDate: vi.fn(() => null) +})); + +function makeSlot(overrides: Partial = {}): ScheduleSlot { + return { + slotId: 'slot-1', + date: '2026-03-05', + dayOfWeek: 4, + startTime: '08:00', + endTime: '09:00', + subjectId: 'sub-1', + subjectName: 'Mathématiques', + teacherId: 'teacher-1', + teacherName: 'M. Dupont', + room: 'Salle 101', + isModified: false, + exceptionId: null, + ...overrides + }; +} + +describe('ScheduleWidget', () => { + it('renders slots with subject, teacher, time and room', () => { + const slot = makeSlot(); + render(ScheduleWidget, { + props: { slots: [slot], nextSlotId: null } + }); + + expect(screen.getByText('Mathématiques')).toBeTruthy(); + expect(screen.getByText('M. Dupont')).toBeTruthy(); + expect(screen.getByText('08:00')).toBeTruthy(); + expect(screen.getByText('09:00')).toBeTruthy(); + expect(screen.getByText('Salle 101')).toBeTruthy(); + }); + + it('shows empty message when no slots', () => { + render(ScheduleWidget, { + props: { slots: [], nextSlotId: null } + }); + + expect(screen.getByText("Aucun cours aujourd'hui")).toBeTruthy(); + }); + + it('shows loading state', () => { + render(ScheduleWidget, { + props: { slots: [], nextSlotId: null, isLoading: true } + }); + + expect(screen.getByText('Chargement...')).toBeTruthy(); + }); + + it('shows error message', () => { + render(ScheduleWidget, { + props: { slots: [], nextSlotId: null, error: 'Erreur réseau' } + }); + + expect(screen.getByText('Erreur réseau')).toBeTruthy(); + }); + + it('highlights next slot with "Prochain" badge', () => { + const slots = [ + makeSlot({ slotId: 'slot-1', startTime: '08:00', endTime: '09:00' }), + makeSlot({ + slotId: 'slot-2', + startTime: '10:00', + endTime: '11:00', + subjectName: 'Français', + teacherName: 'Mme Martin' + }) + ]; + + render(ScheduleWidget, { + props: { slots, nextSlotId: 'slot-2' } + }); + + expect(screen.getByText('Prochain')).toBeTruthy(); + }); + + it('does not show "Prochain" badge when nextSlotId is null', () => { + render(ScheduleWidget, { + props: { slots: [makeSlot()], nextSlotId: null } + }); + + expect(screen.queryByText('Prochain')).toBeNull(); + }); + + it('does not render room when room is null', () => { + const slot = makeSlot({ room: null }); + const { container } = render(ScheduleWidget, { + props: { slots: [slot], nextSlotId: null } + }); + + expect(container.querySelector('.slot-room')).toBeNull(); + }); + + it('renders multiple slots with data-testid', () => { + const slots = [ + makeSlot({ slotId: 'slot-1' }), + makeSlot({ slotId: 'slot-2', subjectName: 'Français' }), + makeSlot({ slotId: 'slot-3', subjectName: 'Histoire' }) + ]; + + const { container } = render(ScheduleWidget, { + props: { slots, nextSlotId: null } + }); + + const items = container.querySelectorAll('[data-testid="schedule-slot"]'); + expect(items.length).toBe(3); + }); +}); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e449e23..558e582 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,6 +19,7 @@ export default defineConfig({ background_color: '#ffffff', display: 'standalone', start_url: '/', + categories: ['education'], icons: [ { src: 'pwa-192x192.png', @@ -36,10 +37,35 @@ export default defineConfig({ type: 'image/png', purpose: 'any maskable' } + ], + shortcuts: [ + { + name: 'Mon emploi du temps', + short_name: 'EDT', + url: '/dashboard/schedule', + description: 'Consulter mon emploi du temps' + } ] }, workbox: { - globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'] + globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'], + runtimeCaching: [ + { + urlPattern: /\/api\/me\/schedule\//, + handler: 'NetworkFirst', + options: { + cacheName: 'schedule-v1', + expiration: { + maxEntries: 90, + maxAgeSeconds: 30 * 24 * 60 * 60 + }, + networkTimeoutSeconds: 5, + cacheableResponse: { + statuses: [0, 200] + } + } + } + ] }, devOptions: { enabled: false, diff --git a/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json new file mode 100644 index 0000000..8529406 --- /dev/null +++ b/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json @@ -0,0 +1 @@ +{"version":"4.0.18","results":[[":frontend/tests/unit/lib/components/molecules/SearchInput/SearchInput.test.ts",{"duration":0,"failed":true}]]} \ No newline at end of file