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:
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal file
141
frontend/e2e/dashboard-responsive-nav.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const STUDENT_EMAIL = 'e2e-dash-nav-student@example.com';
|
||||
const STUDENT_PASSWORD = 'DashNavStudent123';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe('Dashboard Responsive Navigation', () => {
|
||||
test.beforeAll(async () => {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// MOBILE (375x667)
|
||||
// =========================================================================
|
||||
test.describe('Mobile (375x667)', () => {
|
||||
test.use({ viewport: { width: 375, height: 667 } });
|
||||
|
||||
test('shows hamburger button and hides desktop nav', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('opens drawer via hamburger and shows nav links', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Should show navigation links
|
||||
await expect(drawer.getByText('Tableau de bord')).toBeVisible();
|
||||
await expect(drawer.getByText('Mon emploi du temps')).toBeVisible();
|
||||
await expect(drawer.getByText('Paramètres')).toBeVisible();
|
||||
});
|
||||
|
||||
test('closes drawer via close button', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /fermer le menu/i }).click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('closes drawer on overlay click', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const overlay = page.locator('.mobile-overlay');
|
||||
await overlay.click({ position: { x: 350, y: 300 } });
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('navigates via mobile drawer and closes it', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await drawer.getByText('Mon emploi du temps').click();
|
||||
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/dashboard\/schedule/);
|
||||
});
|
||||
|
||||
test('shows logout button in drawer footer', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const logoutButton = drawer.locator('.mobile-logout');
|
||||
await expect(logoutButton).toBeVisible();
|
||||
await expect(logoutButton).toHaveText(/déconnexion/i);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// DESKTOP (1280x800)
|
||||
// =========================================================================
|
||||
test.describe('Desktop (1280x800)', () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test('hides hamburger and shows desktop nav', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).not.toBeVisible();
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('desktop nav shows schedule link for student', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav.getByText('Mon EDT')).toBeVisible({ timeout: 10000 });
|
||||
await expect(desktopNav.getByText('Tableau de bord')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -202,7 +202,7 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
|
||||
// Teacher should not see admin-specific navigation in the dashboard layout
|
||||
// The dashboard header should not have admin links like "Utilisateurs"
|
||||
const adminUsersLink = page.locator('.header-nav').getByRole('link', { name: 'Utilisateurs' });
|
||||
const adminUsersLink = page.locator('.desktop-nav').getByRole('link', { name: 'Utilisateurs' });
|
||||
await expect(adminUsersLink).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
556
frontend/e2e/student-schedule.spec.ts
Normal file
556
frontend/e2e/student-schedule.spec.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const STUDENT_EMAIL = 'e2e-student-schedule@example.com';
|
||||
const STUDENT_PASSWORD = 'StudentSchedule123';
|
||||
const TEACHER_EMAIL = 'e2e-student-sched-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'TeacherSchedule123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ISO day of week (1=Monday ... 5=Friday) for the current day,
|
||||
* clamped to weekdays for schedule slot seeding.
|
||||
*/
|
||||
function currentWeekdayIso(): number {
|
||||
const jsDay = new Date().getDay(); // 0=Sun, 1=Mon...6=Sat
|
||||
if (jsDay === 0) return 1; // Sunday → use Monday
|
||||
if (jsDay === 6) return 5; // Saturday → use Friday
|
||||
return jsDay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of day-back navigations needed to reach the seeded weekday.
|
||||
* 0 on weekdays, 1 on Saturday (→ Friday), 2 on Sunday (→ Friday).
|
||||
*/
|
||||
function daysBackToSeededWeekday(): number {
|
||||
const jsDay = new Date().getDay();
|
||||
if (jsDay === 6) return 1; // Saturday → go back 1 day to Friday
|
||||
if (jsDay === 0) return 2; // Sunday → go back 2 days to Friday
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the French day name for the target seeded weekday.
|
||||
*/
|
||||
function seededDayName(): string {
|
||||
const jsDay = new Date().getDay();
|
||||
// Saturday → Friday, Sunday → Friday, else today
|
||||
const target = jsDay === 6 ? 5 : jsDay === 0 ? 5 : jsDay;
|
||||
return ['dimanche', 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi'][target]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* On weekends, navigate back to the weekday where schedule slots were seeded.
|
||||
* Must be called after the schedule page has loaded.
|
||||
*
|
||||
* Webkit needs time after page render for Svelte 5 event delegation to hydrate.
|
||||
* We retry clicking until the day title changes, with a timeout.
|
||||
*/
|
||||
async function navigateToSeededDay(page: import('@playwright/test').Page) {
|
||||
const back = daysBackToSeededWeekday();
|
||||
if (back === 0) return;
|
||||
|
||||
const targetDay = seededDayName();
|
||||
const targetPattern = new RegExp(targetDay, 'i');
|
||||
const prevBtn = page.getByLabel('Précédent');
|
||||
await expect(prevBtn).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Retry clicking — on webkit, Svelte 5 event delegation needs time to hydrate
|
||||
const deadline = Date.now() + 15000;
|
||||
let navigated = false;
|
||||
while (Date.now() < deadline && !navigated) {
|
||||
for (let i = 0; i < back; i++) {
|
||||
await prevBtn.click();
|
||||
}
|
||||
await page.waitForTimeout(500);
|
||||
const title = await page.locator('.day-title').textContent();
|
||||
if (title && targetPattern.test(title)) {
|
||||
navigated = true;
|
||||
}
|
||||
}
|
||||
|
||||
await expect(page.locator('.day-title').getByText(targetPattern)).toBeVisible({
|
||||
timeout: 5000
|
||||
});
|
||||
}
|
||||
|
||||
async function loginAsStudent(page: import('@playwright/test').Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(STUDENT_EMAIL);
|
||||
await page.locator('#password').fill(STUDENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
test.describe('Student Schedule Consultation (Story 4.3)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Create student user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
// Create teacher user
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
// Ensure class exists
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-StudentSched-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Ensure subject exists
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-StudentSched-Maths', 'E2ESTUMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-StudentSched-Français', 'E2ESTUFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Clean up schedule data for this tenant
|
||||
try {
|
||||
runSql(`DELETE FROM schedule_slots WHERE tenant_id = '${TENANT_ID}' AND class_id IN (SELECT id FROM school_classes WHERE name = 'E2E-StudentSched-6A' AND tenant_id = '${TENANT_ID}')`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Clean up calendar entries to prevent holidays/vacations from blocking schedule resolution
|
||||
try {
|
||||
runSql(`DELETE FROM school_calendar_entries WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Assign student to class
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c ` +
|
||||
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Create schedule slots for the class on today's weekday
|
||||
const dayOfWeek = currentWeekdayIso();
|
||||
runSql(
|
||||
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '09:00', '10:00', 'A101', true, NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2ESTUMATH' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
runSql(
|
||||
`INSERT INTO schedule_slots (id, tenant_id, class_id, subject_id, teacher_id, day_of_week, start_time, end_time, room, is_recurring, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, ${dayOfWeek}, '10:15', '11:15', 'B202', true, NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2ESTUFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-StudentSched-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC1: Day View
|
||||
// ======================================================================
|
||||
test.describe('AC1: Day View', () => {
|
||||
test('student can navigate to schedule page', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('day view is the default view', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Day toggle should be active
|
||||
const dayButton = page.locator('.view-toggle button', { hasText: 'Jour' });
|
||||
await expect(dayButton).toHaveClass(/active/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('day view shows schedule slots for today', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load
|
||||
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||
|
||||
// Should see both slots
|
||||
await expect(slots).toHaveCount(2);
|
||||
|
||||
// Verify slot content
|
||||
await expect(page.getByText('E2E-StudentSched-Maths')).toBeVisible();
|
||||
await expect(page.getByText('E2E-StudentSched-Français')).toBeVisible();
|
||||
await expect(page.getByText('A101')).toBeVisible();
|
||||
await expect(page.getByText('B202')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC2: Day Navigation
|
||||
// ======================================================================
|
||||
test.describe('AC2: Day Navigation', () => {
|
||||
test('navigating to a day with no courses shows empty message', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// On weekends, the current day already has no courses — verify directly
|
||||
const back = daysBackToSeededWeekday();
|
||||
if (back > 0) {
|
||||
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for today's slots to fully load before navigating
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Navigate forward enough days to reach a day with no seeded slots.
|
||||
// Slots are only seeded on today's weekday, so +1 day is guaranteed empty.
|
||||
await page.getByLabel('Suivant').click();
|
||||
|
||||
// The day view should show the empty-state message
|
||||
await expect(page.getByText('Aucun cours ce jour')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('can navigate to next day and back', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load on the seeded day
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Navigate to next day
|
||||
await page.getByLabel('Suivant').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Then navigate back
|
||||
await page.getByLabel('Précédent').click();
|
||||
|
||||
// Slots should be visible again
|
||||
const slots = page.locator('[data-testid="schedule-slot"]');
|
||||
await expect(slots.first()).toBeVisible({ timeout: 20000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC3: Week View
|
||||
// ======================================================================
|
||||
test.describe('AC3: Week View', () => {
|
||||
test('can switch to week view and see grid', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// Week grid should show day headers (proves week view rendered)
|
||||
// Use exact match to avoid strict mode violation with mobile list labels ("Lun 2" etc.)
|
||||
await expect(page.getByText('Lun', { exact: true })).toBeVisible({ timeout: 15000 });
|
||||
await expect(page.getByText('Mar', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Mer', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Jeu', { exact: true })).toBeVisible();
|
||||
await expect(page.getByText('Ven', { exact: true })).toBeVisible();
|
||||
|
||||
// Week slots should be visible (scope to desktop grid to avoid hidden mobile slots)
|
||||
const weekSlots = page.locator('.week-slot-desktop[data-testid="week-slot"]');
|
||||
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('week view shows mobile list layout on small viewport', async ({ page }) => {
|
||||
// Resize to mobile
|
||||
await page.setViewportSize({ width: 375, height: 667 });
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// Mobile list should be visible, desktop grid should be hidden
|
||||
const weekList = page.locator('.week-list');
|
||||
const weekGrid = page.locator('.week-grid');
|
||||
await expect(weekList).toBeVisible({ timeout: 15000 });
|
||||
await expect(weekGrid).not.toBeVisible();
|
||||
|
||||
// Should show day sections with slot count
|
||||
await expect(page.getByText(/\d+ cours/).first()).toBeVisible();
|
||||
|
||||
// Week slots should be visible in mobile layout
|
||||
const weekSlots = page.locator('[data-testid="week-slot"]');
|
||||
await expect(weekSlots.first()).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('week view shows desktop grid on large viewport', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week view
|
||||
const weekButton = page.locator('.view-toggle button', { hasText: 'Semaine' });
|
||||
await weekButton.click();
|
||||
|
||||
// Desktop grid should be visible, mobile list should be hidden
|
||||
const weekList = page.locator('.week-list');
|
||||
const weekGrid = page.locator('.week-grid');
|
||||
await expect(weekGrid).toBeVisible({ timeout: 15000 });
|
||||
await expect(weekList).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can switch back to day view from week view', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for day view to load
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch to week
|
||||
await page.locator('.view-toggle button', { hasText: 'Semaine' }).click();
|
||||
await expect(page.locator('.week-slot-desktop[data-testid="week-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Switch back to day
|
||||
await page.locator('.view-toggle button', { hasText: 'Jour' }).click();
|
||||
|
||||
// Day slots should be visible again (proves day view rendered)
|
||||
await expect(page.locator('[data-testid="schedule-slot"]').first()).toBeVisible({
|
||||
timeout: 15000
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC4: Slot Details
|
||||
// ======================================================================
|
||||
test.describe('AC4: Slot Details', () => {
|
||||
test('clicking a slot opens details modal', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for slots to load
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the slot
|
||||
await firstSlot.click();
|
||||
|
||||
// Modal should appear with course details
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Should show subject, teacher, room, time
|
||||
await expect(dialog.getByText('E2E-StudentSched-Maths')).toBeVisible();
|
||||
await expect(dialog.getByText('09:00 - 10:00')).toBeVisible();
|
||||
await expect(dialog.getByText('A101')).toBeVisible();
|
||||
});
|
||||
|
||||
test('details modal closes with Escape key', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close with Escape
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('details modal closes when clicking overlay', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
const firstSlot = page.locator('[data-testid="schedule-slot"]').first();
|
||||
await expect(firstSlot).toBeVisible({ timeout: 15000 });
|
||||
await firstSlot.click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Close by clicking the overlay (outside the card)
|
||||
await page.locator('.overlay').click({ position: { x: 10, y: 10 } });
|
||||
await expect(dialog).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC5: Offline Mode
|
||||
// ======================================================================
|
||||
test.describe('AC5: Offline Mode', () => {
|
||||
test('shows offline banner when network is lost', async ({ page, context }) => {
|
||||
await loginAsStudent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/schedule`);
|
||||
await navigateToSeededDay(page);
|
||||
|
||||
// Wait for schedule to load (data is now cached by the browser)
|
||||
await expect(
|
||||
page.locator('[data-testid="schedule-slot"]').first()
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Simulate going offline — triggers window 'offline' event
|
||||
await context.setOffline(true);
|
||||
|
||||
// The offline banner should appear
|
||||
const offlineBanner = page.locator('.offline-banner[role="status"]');
|
||||
await expect(offlineBanner).toBeVisible({ timeout: 5000 });
|
||||
await expect(offlineBanner.getByText('Hors ligne')).toBeVisible();
|
||||
await expect(offlineBanner.getByText(/Dernière sync/)).toBeVisible();
|
||||
|
||||
// Restore online
|
||||
await context.setOffline(false);
|
||||
|
||||
// Banner should disappear
|
||||
await expect(offlineBanner).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Navigation link
|
||||
// ======================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('schedule link is visible in dashboard header', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
|
||||
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
|
||||
test('clicking schedule nav link navigates to schedule page', async ({ page }) => {
|
||||
await loginAsStudent(page);
|
||||
|
||||
const navLink = page.locator('.desktop-nav a', { hasText: 'Mon EDT' });
|
||||
await expect(navLink).toBeVisible({ timeout: 10000 });
|
||||
await navLink.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/schedule/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /mon emploi du temps/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -95,7 +95,9 @@ export default tseslint.config(
|
||||
clearTimeout: 'readonly',
|
||||
DragEvent: 'readonly',
|
||||
File: 'readonly',
|
||||
Blob: 'readonly'
|
||||
Blob: 'readonly',
|
||||
HTMLButtonElement: 'readonly',
|
||||
MouseEvent: 'readonly'
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -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>
|
||||
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal file
62
frontend/src/lib/features/schedule/api/schedule.ts
Normal 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;
|
||||
}
|
||||
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal file
58
frontend/src/lib/features/schedule/stores/scheduleCache.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
@@ -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">
|
||||
×
|
||||
</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 {
|
||||
|
||||
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal file
29
frontend/src/routes/dashboard/schedule/+page.svelte
Normal 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>
|
||||
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
70
frontend/tests/unit/features/schedule/scheduleCache.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user