feat: Dashboard placeholder avec preview Score Sérénité

Permet aux parents de visualiser une démo du Score Sérénité dès leur
première connexion, avant même que les données réelles soient disponibles.
Les autres rôles (enseignant, élève, admin) ont également leur dashboard
adapté avec des sections placeholder.

La landing page redirige automatiquement vers /dashboard si l'utilisateur
est déjà authentifié, offrant un accès direct au tableau de bord.
This commit is contained in:
2026-02-04 18:34:08 +01:00
parent d3c6773be5
commit b45ef735db
26 changed files with 3096 additions and 76 deletions

View File

@@ -0,0 +1,36 @@
import { test, expect } from '@playwright/test';
test.describe('Dashboard', () => {
// Dashboard shows demo content without authentication (Story 1.9)
test('shows demo content when not authenticated', async ({ page }) => {
await page.goto('/dashboard');
// Dashboard is accessible without auth - shows demo mode
await expect(page).toHaveURL(/\/dashboard/);
// Role switcher visible (shows demo banner)
await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible();
});
test.describe('when authenticated', () => {
// These tests would run with a logged-in user
// For now, we test the public behavior
test('dashboard page exists and loads', async ({ page }) => {
// First, try to access dashboard
const response = await page.goto('/dashboard');
// The page should load (even if it redirects)
expect(response?.status()).toBeLessThan(500);
});
});
});
test.describe('Dashboard Components', () => {
test('demo data JSON is valid and accessible', async ({ page }) => {
// This tests that the demo data file is bundled correctly
await page.goto('/');
// The app should load without errors
await expect(page.locator('body')).toBeVisible();
});
});

View File

@@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test';
test('home page has correct title and content', async ({ page }) => { test('home page has correct title and content', async ({ page }) => {
await page.goto('/'); await page.goto('/');
await expect(page).toHaveTitle('Classeo'); await expect(page).toHaveTitle('Classeo - Application de gestion scolaire');
await expect(page.getByRole('heading', { name: 'Bienvenue sur Classeo' })).toBeVisible(); await expect(page.getByRole('heading', { name: 'Bienvenue sur Classeo' })).toBeVisible();
await expect(page.getByText('Application de gestion scolaire')).toBeVisible(); await expect(page.getByText('Application de gestion scolaire')).toBeVisible();
}); });

View File

@@ -59,12 +59,12 @@ test.describe('Login Flow', () => {
// Submit and wait for navigation to dashboard // Submit and wait for navigation to dashboard
await Promise.all([ await Promise.all([
page.waitForURL('/', { timeout: 10000 }), page.waitForURL('/dashboard', { timeout: 10000 }),
submitButton.click() submitButton.click()
]); ]);
// We should be on the dashboard (root) // We should be on the dashboard
await expect(page).toHaveURL('/'); await expect(page).toHaveURL('/dashboard');
}); });
}); });
@@ -350,7 +350,7 @@ test.describe('Login Flow', () => {
await submitButton.click(); await submitButton.click();
// Should redirect to dashboard (successful login) // Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/$/, { timeout: 10000 }); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}); });
test('user cannot login on different tenant', async ({ page }) => { test('user cannot login on different tenant', async ({ page }) => {
@@ -383,7 +383,7 @@ test.describe('Login Flow', () => {
await submitButton.click(); await submitButton.click();
// Should redirect to dashboard (successful login) // Should redirect to dashboard (successful login)
await expect(page).toHaveURL(/\/$/, { timeout: 10000 }); await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}); });
}); });
}); });

View File

@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';
test.describe('Navigation and Authentication Flow', () => {
test.describe('Landing page (/)', () => {
test('shows landing page when not authenticated', async ({ page }) => {
// Clear any existing session
await page.context().clearCookies();
await page.goto('/');
// Should show the landing page with login button
await expect(page.getByRole('button', { name: /se connecter/i })).toBeVisible();
await expect(page.getByText(/bienvenue sur/i)).toBeVisible();
});
test('shows Score Serenite feature on landing page', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/');
await expect(page.getByText(/score serenite/i)).toBeVisible();
});
test('login button navigates to login page', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/');
await page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('Post-login redirect', () => {
// This is already tested in login.spec.ts with proper test user setup
// See: login.spec.ts > "logs in successfully and redirects to dashboard"
test.skip('redirects to dashboard after successful login', async ({ page: _page }) => {
// Covered by login.spec.ts which creates test users via Docker
});
});
test.describe('Dashboard access', () => {
test('dashboard is accessible in demo mode (no auth required for placeholder)', async ({ page }) => {
await page.context().clearCookies();
await page.goto('/dashboard');
// Dashboard shows demo content without requiring auth
// This is intentional for the placeholder story (1.9)
await expect(page).toHaveURL(/\/dashboard/);
// Role switcher visible (shows demo banner)
await expect(page.getByText(/Démo - Changer de rôle/i)).toBeVisible();
});
});
test.describe('Header navigation consistency', () => {
// These tests require authentication - skip if no test user available
test.skip('dashboard header has correct navigation links', async ({ page }) => {
// Would need authenticated session
await page.goto('/dashboard');
await expect(page.getByRole('link', { name: /tableau de bord/i })).toBeVisible();
await expect(page.getByRole('link', { name: /parametres/i })).toBeVisible();
await expect(page.getByRole('button', { name: /deconnexion/i })).toBeVisible();
});
test.skip('settings header has correct navigation links', async ({ page }) => {
// Would need authenticated session
await page.goto('/settings');
await expect(page.getByRole('link', { name: /tableau de bord/i })).toBeVisible();
await expect(page.getByRole('link', { name: /parametres/i })).toBeVisible();
await expect(page.getByRole('button', { name: /deconnexion/i })).toBeVisible();
});
test.skip('clicking logo navigates to dashboard', async ({ page }) => {
// Would need authenticated session
await page.goto('/settings');
await page.getByText('Classeo').click();
await expect(page).toHaveURL(/\/dashboard/);
});
});
});
test.describe('Dashboard Demo Features', () => {
test.describe('Role switcher (demo mode)', () => {
test.skip('can switch between roles', async ({ page }) => {
// Would need authenticated session
await page.goto('/dashboard');
// Check role switcher is visible
await expect(page.getByText(/demo.*changer de role/i)).toBeVisible();
// Switch to teacher
await page.getByRole('button', { name: /enseignant/i }).click();
await expect(page.getByText(/tableau de bord enseignant/i)).toBeVisible();
// Switch to student
await page.getByRole('button', { name: /eleve/i }).click();
await expect(page.getByText(/mon espace/i)).toBeVisible();
// Switch to admin
await page.getByRole('button', { name: /admin/i }).click();
await expect(page.getByText(/administration/i)).toBeVisible();
// Switch back to parent
await page.getByRole('button', { name: /parent/i }).click();
await expect(page.getByText(/score serenite/i)).toBeVisible();
});
});
test.describe('Serenity Score Preview', () => {
test.skip('displays demo badge', async ({ page }) => {
// Would need authenticated session
await page.goto('/dashboard');
await expect(page.getByText(/donnees de demonstration/i)).toBeVisible();
});
test.skip('opens explainer modal on click', async ({ page }) => {
// Would need authenticated session
await page.goto('/dashboard');
// Click on serenity score card
await page.getByRole('button', { name: /score serenite/i }).click();
// Modal should appear
await expect(page.getByText(/comment fonctionne le score serenite/i)).toBeVisible();
});
});
});

View File

@@ -49,7 +49,7 @@ async function login(page: import('@playwright/test').Page, email: string) {
await page.locator('#email').fill(email); await page.locator('#email').fill(email);
await page.locator('#password').fill(TEST_PASSWORD); await page.locator('#password').fill(TEST_PASSWORD);
await page.getByRole('button', { name: /se connecter/i }).click(); await page.getByRole('button', { name: /se connecter/i }).click();
await page.waitForURL(getTenantUrl('/'), { timeout: 10000 }); await page.waitForURL(getTenantUrl('/dashboard'), { timeout: 10000 });
} }
test.describe('Sessions Management', () => { test.describe('Sessions Management', () => {
@@ -260,13 +260,13 @@ test.describe('Sessions Management', () => {
await login(page, email); await login(page, email);
await page.goto(getTenantUrl('/settings')); await page.goto(getTenantUrl('/settings'));
// Click logout button and wait for navigation // Click logout button
const logoutButton = page.getByRole('button', { name: /déconnexion/i }); const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
await expect(logoutButton).toBeVisible(); await expect(logoutButton).toBeVisible();
await Promise.all([ await logoutButton.click();
page.waitForURL(/login/, { timeout: 10000 }),
logoutButton.click() // Wait for redirect to login
]); await expect(page).toHaveURL(/login/, { timeout: 10000 });
}); });
test('logout clears authentication', async ({ page, browserName }, testInfo) => { test('logout clears authentication', async ({ page, browserName }, testInfo) => {
@@ -278,13 +278,13 @@ test.describe('Sessions Management', () => {
await login(page, email); await login(page, email);
await page.goto(getTenantUrl('/settings')); await page.goto(getTenantUrl('/settings'));
// Logout - wait for navigation to complete // Logout
const logoutButton = page.getByRole('button', { name: /déconnexion/i }); const logoutButton = page.getByRole('button', { name: /d[eé]connexion/i });
await expect(logoutButton).toBeVisible(); await expect(logoutButton).toBeVisible();
await Promise.all([ await logoutButton.click();
page.waitForURL(/login/, { timeout: 10000 }),
logoutButton.click() // Wait for redirect to login
]); await expect(page).toHaveURL(/login/, { timeout: 10000 });
// Try to access protected page // Try to access protected page
await page.goto(getTenantUrl('/settings/sessions')); await page.goto(getTenantUrl('/settings/sessions'));

View File

@@ -0,0 +1,58 @@
<script lang="ts">
let { message = 'Chargement...' }: { message?: string } = $props();
</script>
<div class="skeleton-card" aria-busy="true" aria-live="polite">
<div class="skeleton-line wide"></div>
<div class="skeleton-line medium"></div>
<div class="skeleton-line short"></div>
{#if message}
<p class="skeleton-message">{message}</p>
{/if}
</div>
<style>
.skeleton-card {
padding: 1.5rem;
background: #f9fafb;
border-radius: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.skeleton-line {
height: 1rem;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
border-radius: 0.25rem;
animation: shimmer 1.5s infinite;
}
.skeleton-line.wide {
width: 100%;
}
.skeleton-line.medium {
width: 75%;
}
.skeleton-line.short {
width: 50%;
}
.skeleton-message {
margin: 0.5rem 0 0;
font-size: 0.875rem;
color: #9ca3af;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
let {
items = 3,
message = 'Chargement...'
}: {
items?: number;
message?: string;
} = $props();
</script>
<div class="skeleton-list" aria-busy="true" aria-live="polite">
{#each Array(items) as _, i (i)}
<div class="skeleton-item">
<div class="skeleton-icon"></div>
<div class="skeleton-content">
<div class="skeleton-line wide"></div>
<div class="skeleton-line short"></div>
</div>
</div>
{/each}
{#if message}
<p class="skeleton-message">{message}</p>
{/if}
</div>
<style>
.skeleton-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.skeleton-item {
display: flex;
gap: 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.skeleton-icon {
width: 2.5rem;
height: 2.5rem;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
border-radius: 0.5rem;
animation: shimmer 1.5s infinite;
flex-shrink: 0;
}
.skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
justify-content: center;
}
.skeleton-line {
height: 0.75rem;
background: linear-gradient(90deg, #e5e7eb 25%, #f3f4f6 50%, #e5e7eb 75%);
background-size: 200% 100%;
border-radius: 0.25rem;
animation: shimmer 1.5s infinite;
}
.skeleton-line.wide {
width: 80%;
}
.skeleton-line.short {
width: 50%;
}
.skeleton-message {
margin: 0;
font-size: 0.875rem;
color: #9ca3af;
text-align: center;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,91 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
subtitle,
isPlaceholder = false,
placeholderMessage = 'Bientôt disponible',
children
}: {
title: string;
subtitle?: string | undefined;
isPlaceholder?: boolean;
placeholderMessage?: string;
children?: Snippet;
} = $props();
</script>
<section class="dashboard-section" class:placeholder={isPlaceholder}>
<header class="section-header">
<h2 class="section-title">{title}</h2>
{#if subtitle}
<p class="section-subtitle">{subtitle}</p>
{/if}
</header>
<div class="section-content">
{#if isPlaceholder}
<div class="placeholder-content">
<span class="placeholder-icon">🚧</span>
<p class="placeholder-message">{placeholderMessage}</p>
</div>
{:else if children}
{@render children()}
{/if}
</div>
</section>
<style>
.dashboard-section {
background: white;
border-radius: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.section-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.section-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.section-subtitle {
margin: 0.25rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
.section-content {
padding: 1.5rem;
}
.placeholder .section-content {
background: #f9fafb;
}
.placeholder-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 2rem;
text-align: center;
}
.placeholder-icon {
font-size: 2rem;
}
.placeholder-message {
margin: 0;
color: #6b7280;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,449 @@
<script lang="ts">
import type { SerenityScore } from '$types';
import { getSerenityEmoji, getSerenityLabel } from '$lib/features/dashboard/serenity-score';
let {
score,
isEnabled = false,
isDemo = false,
onClose,
onToggleOptIn
}: {
score: SerenityScore;
isEnabled?: boolean;
isDemo?: boolean;
onClose: () => void;
onToggleOptIn?: ((enabled: boolean) => void) | undefined;
} = $props();
let localEnabled = $state(isEnabled);
// Sync local state with parent prop changes
$effect(() => {
localEnabled = isEnabled;
});
function handleToggle() {
localEnabled = !localEnabled;
onToggleOptIn?.(localEnabled);
}
function handleKeydown(event: globalThis.KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
function handleBackdropClick(event: globalThis.MouseEvent) {
if (event.target === event.currentTarget) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="modal-backdrop" onclick={handleBackdropClick}>
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<header class="modal-header">
<h2 id="modal-title">Comment fonctionne le Score Sérénité ?</h2>
<button type="button" class="close-button" onclick={onClose} aria-label="Fermer">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</header>
<div class="modal-body">
{#if isDemo}
<div class="demo-notice">
<span class="demo-icon"></span>
<p>Ces données sont des exemples. Votre Score Sérénité sera calculé à partir des vraies données scolaires de votre enfant.</p>
</div>
{/if}
<section class="score-overview">
<div class="current-score">
<span class="emoji">{score.emoji}</span>
<span class="value">{score.value}</span>
<span class="label">{getSerenityLabel(score.value)}</span>
</div>
</section>
<section class="calculation-section">
<h3>Calcul du score</h3>
<p class="formula">Score = (Notes × 40%) + (Absences × 30%) + (Devoirs × 30%)</p>
<div class="components-grid">
<div class="component-card">
<div class="component-header">
<span class="component-emoji">{getSerenityEmoji(score.components.notes.score)}</span>
<span class="component-name">Notes</span>
<span class="component-weight">40%</span>
</div>
<div class="component-score">{score.components.notes.score}/100</div>
<p class="component-label">{score.components.notes.label}</p>
</div>
<div class="component-card">
<div class="component-header">
<span class="component-emoji">{getSerenityEmoji(score.components.absences.score)}</span>
<span class="component-name">Absences</span>
<span class="component-weight">30%</span>
</div>
<div class="component-score">{score.components.absences.score}/100</div>
<p class="component-label">{score.components.absences.label}</p>
</div>
<div class="component-card">
<div class="component-header">
<span class="component-emoji">{getSerenityEmoji(score.components.devoirs.score)}</span>
<span class="component-name">Devoirs</span>
<span class="component-weight">30%</span>
</div>
<div class="component-score">{score.components.devoirs.score}/100</div>
<p class="component-label">{score.components.devoirs.label}</p>
</div>
</div>
</section>
<section class="legend-section">
<h3>Légende</h3>
<div class="legend-items">
<div class="legend-item">
<span class="legend-emoji">💚</span>
<span class="legend-range">70-100</span>
<span class="legend-label">Excellent</span>
</div>
<div class="legend-item">
<span class="legend-emoji">🟡</span>
<span class="legend-range">40-69</span>
<span class="legend-label">A surveiller</span>
</div>
<div class="legend-item">
<span class="legend-emoji">🔴</span>
<span class="legend-range">0-39</span>
<span class="legend-label">Attention requise</span>
</div>
</div>
</section>
{#if onToggleOptIn}
<section class="opt-in-section">
<h3>Paramètres</h3>
<label class="toggle-label">
<span class="toggle-text">Activer le Score Sérénité</span>
<button
type="button"
class="toggle-switch"
class:enabled={localEnabled}
onclick={handleToggle}
role="switch"
aria-checked={localEnabled}
aria-label="Activer le Score Sérénité"
>
<span class="toggle-knob"></span>
</button>
</label>
<p class="opt-in-description">
{#if localEnabled}
Le Score Sérénité est actif. Vous recevrez des mises à jour régulières.
{:else}
Activez le Score Sérénité pour suivre la progression scolaire de votre enfant en un coup d'œil.
{/if}
</p>
</section>
{/if}
</div>
<footer class="modal-footer">
<button type="button" class="btn-primary" onclick={onClose}>Compris</button>
</footer>
</div>
</div>
<style>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.modal-content {
background: white;
border-radius: 1rem;
max-width: 600px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.close-button {
padding: 0.5rem;
background: none;
border: none;
border-radius: 0.5rem;
cursor: pointer;
color: #6b7280;
transition: color 0.2s, background 0.2s;
}
.close-button:hover {
color: #1f2937;
background: #f3f4f6;
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.demo-notice {
display: flex;
gap: 0.75rem;
padding: 1rem;
background: #fef3c7;
border-radius: 0.5rem;
color: #92400e;
}
.demo-notice p {
margin: 0;
font-size: 0.875rem;
}
.score-overview {
display: flex;
justify-content: center;
}
.current-score {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.current-score .emoji {
font-size: 4rem;
}
.current-score .value {
font-size: 2.5rem;
font-weight: 700;
color: #1f2937;
}
.current-score .label {
font-size: 1rem;
color: #6b7280;
}
.calculation-section h3,
.legend-section h3,
.opt-in-section h3 {
margin: 0 0 1rem;
font-size: 1rem;
font-weight: 600;
color: #374151;
}
.formula {
margin: 0 0 1rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
font-family: monospace;
font-size: 0.875rem;
color: #4b5563;
text-align: center;
}
.components-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.component-card {
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
text-align: center;
}
.component-header {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.component-emoji {
font-size: 1.25rem;
}
.component-name {
font-weight: 600;
color: #374151;
}
.component-weight {
font-size: 0.75rem;
padding: 0.125rem 0.375rem;
background: #e5e7eb;
border-radius: 0.25rem;
color: #6b7280;
}
.component-score {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.component-label {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: #6b7280;
}
.legend-items {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.legend-emoji {
font-size: 1.25rem;
}
.legend-range {
font-weight: 600;
color: #374151;
}
.legend-label {
color: #6b7280;
}
.opt-in-section {
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
.toggle-label {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
}
.toggle-text {
font-weight: 500;
color: #374151;
}
.toggle-switch {
position: relative;
width: 3rem;
height: 1.5rem;
background: #d1d5db;
border: none;
border-radius: 0.75rem;
cursor: pointer;
transition: background 0.2s;
}
.toggle-switch.enabled {
background: #22c55e;
}
.toggle-knob {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
background: white;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s;
}
.toggle-switch.enabled .toggle-knob {
transform: translateX(1.5rem);
}
.opt-in-description {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
}
.btn-primary {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #2563eb;
}
</style>

View File

@@ -0,0 +1,137 @@
<script lang="ts">
type Emoji = '💚' | '🟡' | '🔴';
let {
value,
emoji,
isDemo = false,
onClick
}: {
value: number;
emoji: Emoji;
isDemo?: boolean;
onClick?: () => void;
} = $props();
let isHovered = $state(false);
</script>
<button
type="button"
class="serenity-card"
class:hovered={isHovered}
onclick={onClick}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
aria-label="Score Sérénité - Cliquez pour plus de détails"
>
<div class="card-header">
<h3 class="title">Score Sérénité</h3>
{#if isDemo}
<span class="demo-badge">Données de démonstration</span>
{/if}
</div>
<div class="score-display">
<span class="emoji" class:animated={isHovered}>{emoji}</span>
<span class="value">{value}</span>
<span class="max">/100</span>
</div>
<p class="hint">Cliquez pour en savoir plus</p>
</button>
<style>
.serenity-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
padding: 1.5rem;
background: var(--surface-elevated, #ffffff);
border: 1px solid var(--border-subtle, #e5e7eb);
border-radius: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
width: 100%;
text-align: center;
}
.serenity-card:hover,
.serenity-card.hovered {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.serenity-card:focus-visible {
outline: 2px solid var(--accent-primary, #0ea5e9);
outline-offset: 2px;
}
.card-header {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1f2937);
}
.demo-badge {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--color-attention, #f59e0b);
color: white;
border-radius: 0.25rem;
font-weight: 500;
}
.score-display {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.emoji {
font-size: 3rem;
transition: transform 0.3s ease;
}
.emoji.animated {
animation: bounce 0.5s ease;
}
.value {
font-size: 3rem;
font-weight: 700;
color: var(--text-primary, #1f2937);
}
.max {
font-size: 1.25rem;
color: var(--text-secondary, #6b7280);
}
.hint {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary, #6b7280);
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
</style>

View File

@@ -0,0 +1,233 @@
<script lang="ts">
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let {
isLoading = false,
hasRealData = false,
establishmentName = ''
}: {
isLoading?: boolean;
hasRealData?: boolean;
establishmentName?: string;
} = $props();
</script>
<div class="dashboard-admin">
<header class="dashboard-header">
<h1>Administration</h1>
{#if establishmentName}
<p class="dashboard-subtitle">{establishmentName}</p>
{:else}
<p class="dashboard-subtitle">Bienvenue dans votre espace d'administration</p>
{/if}
</header>
<div class="quick-actions">
<h2 class="sr-only">Actions de configuration</h2>
<div class="action-cards">
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">👥</span>
<span class="action-label">Gérer les utilisateurs</span>
<span class="action-hint">Bientôt disponible</span>
</div>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">🏫</span>
<span class="action-label">Configurer les classes</span>
<span class="action-hint">Bientôt disponible</span>
</div>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">📅</span>
<span class="action-label">Calendrier scolaire</span>
<span class="action-hint">Bientôt disponible</span>
</div>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">📤</span>
<span class="action-label">Importer des données</span>
<span class="action-hint">Bientôt disponible</span>
</div>
</div>
</div>
<div class="dashboard-grid">
<DashboardSection
title="Utilisateurs"
isPlaceholder={!hasRealData}
placeholderMessage="Les statistiques utilisateurs s'afficheront une fois les comptes créés"
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des statistiques..." />
{:else}
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value">--</span>
<span class="stat-label">Élèves</span>
</div>
<div class="stat-card">
<span class="stat-value">--</span>
<span class="stat-label">Enseignants</span>
</div>
<div class="stat-card">
<span class="stat-value">--</span>
<span class="stat-label">Parents</span>
</div>
</div>
{/if}
{/if}
</DashboardSection>
<DashboardSection
title="Configuration"
isPlaceholder={!hasRealData}
placeholderMessage="L'état de la configuration sera affiché ici"
>
{#if hasRealData && isLoading}
<SkeletonList items={4} message="Vérification de la configuration..." />
{/if}
</DashboardSection>
<DashboardSection
title="Activité récente"
isPlaceholder={!hasRealData}
placeholderMessage="L'historique des actions s'affichera ici"
>
{#if hasRealData && isLoading}
<SkeletonList items={5} message="Chargement de l'activité..." />
{/if}
</DashboardSection>
</div>
</div>
<style>
.dashboard-admin {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.dashboard-header {
margin-bottom: 0.5rem;
}
.dashboard-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.dashboard-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.quick-actions {
margin-bottom: 0.5rem;
}
.action-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
text-decoration: none;
color: inherit;
transition: all 0.2s;
}
.action-card:not(.disabled):hover {
border-color: #3b82f6;
background: #eff6ff;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.action-card.disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-icon {
font-size: 2rem;
}
.action-label {
font-weight: 600;
color: #374151;
text-align: center;
}
.action-hint {
font-size: 0.75rem;
color: #6b7280;
text-align: center;
}
.dashboard-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(3, 1fr);
}
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
}
.stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 1rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.stat-label {
font-size: 0.75rem;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,359 @@
<script lang="ts">
import type { DemoData } from '$types';
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonCard from '$lib/components/atoms/Skeleton/SkeletonCard.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import type { SerenityEmoji } from '$lib/features/dashboard/serenity-score';
let {
demoData,
isFirstLogin = false,
isLoading = false,
hasRealData = false,
serenityEnabled = false,
childName = '',
onToggleSerenity
}: {
demoData: DemoData;
isFirstLogin?: boolean;
isLoading?: boolean;
hasRealData?: boolean;
serenityEnabled?: boolean;
childName?: string;
onToggleSerenity?: (enabled: boolean) => void;
} = $props();
let showExplainer = $state(false);
const isDemo = $derived(!hasRealData);
function openExplainer() {
showExplainer = true;
}
function closeExplainer() {
showExplainer = false;
}
</script>
<div class="dashboard-parent">
{#if isFirstLogin}
<div class="onboarding-banner">
<div class="onboarding-content">
<span class="onboarding-icon">👋</span>
<div class="onboarding-text">
<h2>Bienvenue sur Classeo !</h2>
<p>
Découvrez le Score Sérénité, un indicateur unique qui vous donne en un coup d'œil une vision globale de la scolarité de votre enfant.
{#if isDemo}
Les données actuelles sont des exemples. Vos vraies données apparaîtront bientôt.
{/if}
</p>
</div>
</div>
</div>
{/if}
<div class="dashboard-grid">
<!-- Score Serenite - Always visible as demo or real -->
<div class="score-section">
{#if isLoading}
<SkeletonCard message={childName ? `Calcul du Score Sérénité de ${childName}...` : 'Calcul du Score Sérénité...'} />
{:else}
<SerenityScorePreview
value={demoData.serenityScore.value}
emoji={demoData.serenityScore.emoji as SerenityEmoji}
isDemo={isDemo}
onClick={openExplainer}
/>
{/if}
</div>
<!-- EDT Section -->
<DashboardSection
title="Emploi du temps"
subtitle={hasRealData ? "Aujourd'hui" : undefined}
isPlaceholder={!hasRealData}
placeholderMessage="L'emploi du temps sera disponible une fois les cours configurés"
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={4} message={childName ? `Chargement de l'emploi du temps de ${childName}...` : "Chargement de l'emploi du temps..."} />
{:else}
<ul class="schedule-list">
{#each demoData.schedule.today as item}
<li class="schedule-item">
<span class="schedule-time">{item.time}</span>
<span class="schedule-subject">{item.subject}</span>
<span class="schedule-room">{item.room}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
<!-- Notes Section -->
<DashboardSection
title="Notes récentes"
isPlaceholder={!hasRealData}
placeholderMessage="Les notes apparaîtront ici dès qu'elles seront saisies"
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Récupération des notes..." />
{:else}
<ul class="grades-list">
{#each demoData.grades.recent as grade}
<li class="grade-item">
<span class="grade-subject">{grade.subject}</span>
<span class="grade-value">{grade.value}/{grade.max}</span>
<span class="grade-eval">{grade.evaluation}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
<!-- Devoirs Section -->
<DashboardSection
title="Devoirs à venir"
isPlaceholder={!hasRealData}
placeholderMessage="Les devoirs seront affichés ici une fois assignés"
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
<ul class="homework-list">
{#each demoData.homework.upcoming as homework}
<li class="homework-item" class:done={homework.status === 'done'}>
<span class="homework-subject">{homework.subject}</span>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {homework.dueDate}</span>
{#if homework.status === 'done'}
<span class="homework-status done">✓ Fait</span>
{:else if homework.status === 'late'}
<span class="homework-status late">En retard</span>
{/if}
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
</div>
</div>
{#if showExplainer}
<SerenityScoreExplainer
score={demoData.serenityScore}
isEnabled={serenityEnabled}
isDemo={isDemo}
onClose={closeExplainer}
onToggleOptIn={onToggleSerenity}
/>
{/if}
<style>
.dashboard-parent {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.onboarding-banner {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border-radius: 1rem;
color: white;
overflow: hidden;
}
.onboarding-content {
display: flex;
gap: 1rem;
padding: 1.5rem;
align-items: flex-start;
}
.onboarding-icon {
font-size: 2rem;
flex-shrink: 0;
}
.onboarding-text h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
.onboarding-text p {
margin: 0;
font-size: 0.875rem;
opacity: 0.9;
line-height: 1.5;
}
.dashboard-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
.score-section {
grid-column: 1 / -1;
}
}
@media (min-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(3, 1fr);
}
.score-section {
grid-column: 1 / 2;
}
}
.score-section {
display: flex;
}
/* Schedule List */
.schedule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.schedule-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 1rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
align-items: center;
}
.schedule-time {
font-weight: 600;
color: #3b82f6;
font-size: 0.875rem;
}
.schedule-subject {
font-weight: 500;
color: #1f2937;
}
.schedule-room {
font-size: 0.875rem;
color: #6b7280;
}
/* Grades List */
.grades-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.grade-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.grade-subject {
font-weight: 500;
color: #1f2937;
}
.grade-value {
font-weight: 600;
color: #22c55e;
}
.grade-eval {
font-size: 0.875rem;
color: #6b7280;
grid-column: 1 / -1;
}
/* Homework List */
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.homework-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
}
.homework-item.done {
border-left-color: #22c55e;
opacity: 0.7;
}
.homework-subject {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
}
.homework-title {
font-weight: 500;
color: #1f2937;
}
.homework-due {
font-size: 0.875rem;
color: #6b7280;
}
.homework-status {
font-size: 0.75rem;
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
width: fit-content;
}
.homework-status.done {
background: #dcfce7;
color: #166534;
}
.homework-status.late {
background: #fee2e2;
color: #991b1b;
}
</style>

View File

@@ -0,0 +1,330 @@
<script lang="ts">
import type { DemoData } from '$types';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let {
demoData,
isLoading = false,
hasRealData = false,
isMinor = true
}: {
demoData: DemoData;
isLoading?: boolean;
hasRealData?: boolean;
isMinor?: boolean;
} = $props();
</script>
<div class="dashboard-student">
<header class="dashboard-header">
<h1>Mon espace</h1>
<p class="dashboard-subtitle">
{#if isMinor}
Bienvenue ! Voici ton tableau de bord.
{:else}
Bienvenue ! Voici votre tableau de bord.
{/if}
</p>
</header>
{#if !hasRealData}
<div class="info-banner">
<span class="info-icon">📚</span>
<p>
{#if isMinor}
Ton emploi du temps, tes notes et tes devoirs apparaîtront ici bientôt !
{:else}
Votre emploi du temps, vos notes et vos devoirs apparaîtront ici bientôt !
{/if}
</p>
</div>
{/if}
<div class="dashboard-grid">
<!-- EDT Section -->
<DashboardSection
title="Mon emploi du temps"
subtitle={hasRealData ? "Aujourd'hui" : undefined}
isPlaceholder={!hasRealData}
placeholderMessage={isMinor ? "Ton emploi du temps sera bientôt disponible" : "Votre emploi du temps sera bientôt disponible"}
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={4} message="Chargement de l'emploi du temps..." />
{:else}
<ul class="schedule-list">
{#each demoData.schedule.today as item}
<li class="schedule-item">
<span class="schedule-time">{item.time}</span>
<span class="schedule-subject">{item.subject}</span>
<span class="schedule-room">Salle {item.room}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
<!-- Notes Section -->
<DashboardSection
title="Mes notes"
subtitle={hasRealData ? "Dernières notes" : undefined}
isPlaceholder={!hasRealData}
placeholderMessage={isMinor ? "Tes notes apparaîtront ici" : "Vos notes apparaîtront ici"}
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des notes..." />
{:else}
<ul class="grades-list">
{#each demoData.grades.recent as grade}
<li class="grade-item">
<div class="grade-header">
<span class="grade-subject">{grade.subject}</span>
<span class="grade-value">{grade.value}/{grade.max}</span>
</div>
<span class="grade-eval">{grade.evaluation}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
<!-- Devoirs Section -->
<DashboardSection
title="Mes devoirs"
subtitle={hasRealData ? "À faire" : undefined}
isPlaceholder={!hasRealData}
placeholderMessage={isMinor ? "Tes devoirs s'afficheront ici" : "Vos devoirs s'afficheront ici"}
>
{#if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
<ul class="homework-list">
{#each demoData.homework.upcoming as homework}
<li class="homework-item" class:done={homework.status === 'done'}>
<div class="homework-header">
<span class="homework-subject">{homework.subject}</span>
{#if homework.status === 'done'}
<span class="homework-badge done">Fait ✓</span>
{:else if homework.status === 'late'}
<span class="homework-badge late">En retard</span>
{/if}
</div>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {homework.dueDate}</span>
</li>
{/each}
</ul>
{/if}
{/if}
</DashboardSection>
</div>
</div>
<style>
.dashboard-student {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.dashboard-header {
margin-bottom: 0.5rem;
}
.dashboard-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.dashboard-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.info-banner {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.75rem;
color: #1e40af;
}
.info-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.info-banner p {
margin: 0;
font-size: 0.875rem;
}
.dashboard-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.dashboard-grid {
grid-template-columns: repeat(3, 1fr);
}
}
/* Schedule List */
.schedule-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.schedule-item {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.75rem;
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
align-items: center;
}
.schedule-time {
font-weight: 600;
color: #3b82f6;
font-size: 0.875rem;
min-width: 3rem;
}
.schedule-subject {
font-weight: 500;
color: #1f2937;
}
.schedule-room {
font-size: 0.75rem;
color: #6b7280;
}
/* Grades List */
.grades-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.grade-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.grade-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.grade-subject {
font-weight: 500;
color: #1f2937;
}
.grade-value {
font-weight: 700;
color: #22c55e;
font-size: 1.125rem;
}
.grade-eval {
font-size: 0.875rem;
color: #6b7280;
}
/* Homework List */
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.homework-item {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
}
.homework-item.done {
border-left-color: #22c55e;
opacity: 0.7;
}
.homework-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.homework-subject {
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-transform: uppercase;
}
.homework-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-weight: 500;
}
.homework-badge.done {
background: #dcfce7;
color: #166534;
}
.homework-badge.late {
background: #fee2e2;
color: #991b1b;
}
.homework-title {
display: block;
font-weight: 500;
color: #1f2937;
margin-bottom: 0.25rem;
}
.homework-due {
font-size: 0.875rem;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,172 @@
<script lang="ts">
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let {
isLoading = false,
hasRealData = false
}: {
isLoading?: boolean;
hasRealData?: boolean;
} = $props();
</script>
<div class="dashboard-teacher">
<header class="dashboard-header">
<h1>Tableau de bord Enseignant</h1>
<p class="dashboard-subtitle">Bienvenue ! Voici vos outils du jour.</p>
</header>
<div class="quick-actions">
<h2 class="sr-only">Actions rapides</h2>
<div class="action-cards">
<button type="button" class="action-card" disabled={!hasRealData}>
<span class="action-icon">📋</span>
<span class="action-label">Faire l'appel</span>
</button>
<button type="button" class="action-card" disabled={!hasRealData}>
<span class="action-icon">📝</span>
<span class="action-label">Saisir des notes</span>
</button>
<button type="button" class="action-card" disabled={!hasRealData}>
<span class="action-icon">📚</span>
<span class="action-label">Créer un devoir</span>
</button>
</div>
</div>
<div class="dashboard-grid">
<DashboardSection
title="Mes classes aujourd'hui"
isPlaceholder={!hasRealData}
placeholderMessage="Vos classes apparaîtront ici une fois l'emploi du temps configuré"
>
{#if hasRealData && isLoading}
<SkeletonList items={4} message="Chargement des classes..." />
{/if}
</DashboardSection>
<DashboardSection
title="Notes à saisir"
subtitle="Évaluations en attente"
isPlaceholder={!hasRealData}
placeholderMessage="Les évaluations en attente de notation seront listées ici"
>
{#if hasRealData && isLoading}
<SkeletonList items={3} message="Chargement des évaluations..." />
{/if}
</DashboardSection>
<DashboardSection
title="Appels du jour"
isPlaceholder={!hasRealData}
placeholderMessage="Les appels à effectuer s'afficheront ici"
>
{#if hasRealData && isLoading}
<SkeletonList items={3} message="Chargement des appels..." />
{/if}
</DashboardSection>
<DashboardSection
title="Statistiques rapides"
isPlaceholder={!hasRealData}
placeholderMessage="Les statistiques de vos classes seront disponibles prochainement"
>
{#if hasRealData && isLoading}
<SkeletonList items={2} message="Chargement des statistiques..." />
{/if}
</DashboardSection>
</div>
</div>
<style>
.dashboard-teacher {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.dashboard-header {
margin-bottom: 0.5rem;
}
.dashboard-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.dashboard-subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.quick-actions {
margin-bottom: 0.5rem;
}
.action-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 1rem;
}
.action-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.25rem 1rem;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s;
}
.action-card:hover:not(:disabled) {
border-color: #3b82f6;
background: #eff6ff;
}
.action-card:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.action-icon {
font-size: 1.75rem;
}
.action-label {
font-weight: 500;
color: #374151;
text-align: center;
font-size: 0.875rem;
}
.dashboard-grid {
display: grid;
gap: 1.5rem;
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View File

@@ -0,0 +1,102 @@
{
"serenityScore": {
"value": 86,
"emoji": "💚",
"trend": "stable",
"components": {
"notes": {
"score": 90,
"label": "Excellentes notes"
},
"absences": {
"score": 95,
"label": "Très peu d'absences"
},
"devoirs": {
"score": 70,
"label": "Quelques devoirs en retard"
}
}
},
"schedule": {
"today": [
{
"time": "08:00",
"subject": "Mathématiques",
"room": "A101"
},
{
"time": "09:00",
"subject": "Français",
"room": "B203"
},
{
"time": "10:15",
"subject": "Histoire-Géographie",
"room": "C105"
},
{
"time": "11:15",
"subject": "Anglais",
"room": "D201"
},
{
"time": "14:00",
"subject": "Sciences Physiques",
"room": "Labo 1"
},
{
"time": "15:00",
"subject": "Éducation Physique",
"room": "Gymnase"
}
]
},
"grades": {
"recent": [
{
"subject": "Mathématiques",
"value": 16,
"max": 20,
"date": "2026-01-28",
"evaluation": "Contrôle équations"
},
{
"subject": "Français",
"value": 14,
"max": 20,
"date": "2026-01-25",
"evaluation": "Dissertation"
},
{
"subject": "Anglais",
"value": 17,
"max": 20,
"date": "2026-01-22",
"evaluation": "Compréhension orale"
}
]
},
"homework": {
"upcoming": [
{
"subject": "Mathématiques",
"title": "Exercices chapitre 5",
"dueDate": "2026-02-06",
"status": "pending"
},
{
"subject": "Français",
"title": "Lecture Le Rouge et le Noir (chap. 1-5)",
"dueDate": "2026-02-08",
"status": "pending"
},
{
"subject": "Histoire-Géographie",
"title": "Fiche révision WWI",
"dueDate": "2026-02-05",
"status": "done"
}
]
}
}

View File

@@ -0,0 +1,59 @@
import type { SerenityScore } from '$types';
export type SerenityEmoji = '💚' | '🟡' | '🔴';
/**
* Calculates the serenity score from its components
* Score = (Notes × 0.4) + (Absences × 0.3) + (Devoirs × 0.3)
*/
export function calculateSerenityScore(components: {
notes: number;
absences: number;
devoirs: number;
}): number {
const score = components.notes * 0.4 + components.absences * 0.3 + components.devoirs * 0.3;
return Math.round(score);
}
/**
* Gets the appropriate emoji for a serenity score
* 💚 Green: Score >= 70
* 🟡 Orange: Score 40-69
* 🔴 Red: Score < 40
*/
export function getSerenityEmoji(score: number): SerenityEmoji {
if (score >= 70) return '💚';
if (score >= 40) return '🟡';
return '🔴';
}
/**
* Gets a human-readable label for the serenity score
*/
export function getSerenityLabel(score: number): string {
if (score >= 70) return 'Excellent';
if (score >= 40) return 'A surveiller';
return 'Attention requise';
}
/**
* Validates that a serenity score value is within valid bounds
*/
export function isValidScore(score: number): boolean {
return score >= 0 && score <= 100;
}
/**
* Gets trend icon for score comparison
*/
export function getTrendIcon(trend: SerenityScore['trend']): string {
switch (trend) {
case 'up':
return '↑';
case 'down':
return '↓';
case 'stable':
default:
return '→';
}
}

View File

@@ -0,0 +1,55 @@
export interface SerenityComponent {
score: number;
label: string;
}
export interface SerenityScore {
value: number;
emoji: '💚' | '🟡' | '🔴';
trend: 'stable' | 'up' | 'down';
components: {
notes: SerenityComponent;
absences: SerenityComponent;
devoirs: SerenityComponent;
};
}
export interface ScheduleItem {
time: string;
subject: string;
room: string;
}
export interface Schedule {
today: ScheduleItem[];
}
export interface GradeItem {
subject: string;
value: number;
max: number;
date: string;
evaluation: string;
}
export interface Grades {
recent: GradeItem[];
}
export interface HomeworkItem {
subject: string;
title: string;
dueDate: string;
status: 'pending' | 'done' | 'late';
}
export interface Homework {
upcoming: HomeworkItem[];
}
export interface DemoData {
serenityScore: SerenityScore;
schedule: Schedule;
grades: Grades;
homework: Homework;
}

View File

@@ -1,2 +1,3 @@
export * from './shared'; export * from './shared';
export * from './api'; export * from './api';
export * from './demo';

View File

@@ -1,28 +1,208 @@
<script lang="ts"> <script lang="ts">
let count = $state(0); import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { refreshToken, isAuthenticated } from '$lib/auth/auth.svelte';
function increment() { let isChecking = $state(true);
count++;
// Check if user is authenticated and redirect to dashboard
$effect(() => {
if (browser) {
checkAuthAndRedirect();
}
});
async function checkAuthAndRedirect() {
// If already has token in memory, redirect immediately
if (isAuthenticated()) {
goto('/dashboard');
return;
}
// Try to refresh token from cookie
const refreshed = await refreshToken();
if (refreshed) {
goto('/dashboard');
return;
}
// Not authenticated, show landing page
isChecking = false;
}
function goToLogin() {
goto('/login');
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Classeo</title> <title>Classeo - Application de gestion scolaire</title>
</svelte:head> </svelte:head>
<main class="flex min-h-screen flex-col items-center justify-center bg-gray-50"> {#if isChecking}
<div class="text-center"> <main class="landing">
<h1 class="mb-4 text-4xl font-bold text-primary">Bienvenue sur Classeo</h1> <div class="loading">
<p class="mb-8 text-gray-600">Application de gestion scolaire</p> <div class="spinner"></div>
<p>Chargement...</p>
</div>
</main>
{:else}
<main class="landing">
<div class="hero">
<h1>Bienvenue sur <span class="brand">Classeo</span></h1>
<p class="tagline">L'application de gestion scolaire qui simplifie le quotidien des familles et des enseignants</p>
<div class="rounded-lg bg-white p-8 shadow-md"> <div class="cta">
<p class="mb-4 text-2xl font-semibold text-gray-800">Compteur: {count}</p> <button class="btn-primary" onclick={goToLogin}>
<button Se connecter
onclick={increment}
class="rounded-md bg-primary px-6 py-2 text-primary-foreground transition-colors hover:bg-primary/90"
>
Incrementer
</button> </button>
</div> </div>
<div class="features">
<div class="feature">
<span class="feature-icon">💚</span>
<h3>Score Serenite</h3>
<p>Suivez la scolarite de votre enfant en un coup d'oeil</p>
</div>
<div class="feature">
<span class="feature-icon">📅</span>
<h3>Emploi du temps</h3>
<p>Consultez les cours et les modifications en temps reel</p>
</div>
<div class="feature">
<span class="feature-icon">📝</span>
<h3>Notes et devoirs</h3>
<p>Restez informe des resultats et des travaux a faire</p>
</div>
</div>
</div> </div>
</main> </main>
{/if}
<style>
.landing {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f8fafc 0%, #e0f2fe 100%);
padding: 2rem;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
color: #64748b;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.hero {
max-width: 800px;
text-align: center;
}
h1 {
font-size: 2.5rem;
font-weight: 700;
color: #1e293b;
margin: 0 0 1rem;
}
.brand {
color: #3b82f6;
}
.tagline {
font-size: 1.25rem;
color: #64748b;
margin: 0 0 2rem;
line-height: 1.6;
}
.cta {
margin-bottom: 3rem;
}
.btn-primary {
padding: 1rem 2.5rem;
font-size: 1.125rem;
font-weight: 600;
color: white;
background: #3b82f6;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
}
.btn-primary:hover {
background: #2563eb;
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(59, 130, 246, 0.5);
}
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 2rem;
margin-top: 2rem;
}
.feature {
padding: 1.5rem;
background: white;
border-radius: 1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.feature-icon {
font-size: 2.5rem;
display: block;
margin-bottom: 0.75rem;
}
.feature h3 {
margin: 0 0 0.5rem;
font-size: 1.125rem;
font-weight: 600;
color: #1e293b;
}
.feature p {
margin: 0;
font-size: 0.875rem;
color: #64748b;
line-height: 1.5;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 640px) {
h1 {
font-size: 1.75rem;
}
.tagline {
font-size: 1rem;
}
.features {
gap: 1rem;
}
}
</style>

View File

@@ -0,0 +1,208 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { logout } from '$lib/auth/auth.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
// Note: Authentication is handled by authenticatedFetch in the page component.
// If not authenticated, authenticatedFetch will attempt refresh and redirect to /login if needed.
async function handleLogout() {
isLoggingOut = true;
try {
await logout();
} finally {
isLoggingOut = false;
}
}
function goHome() {
goto('/dashboard');
}
function goSettings() {
goto('/settings');
}
</script>
<div class="dashboard-layout">
<header class="dashboard-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
<button class="nav-button" onclick={goSettings}>Parametres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
Deconnexion...
{:else}
Deconnexion
{/if}
</button>
</nav>
</div>
</header>
<main class="dashboard-main">
<div class="main-content">
{@render children()}
</div>
</main>
</div>
<style>
.dashboard-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--surface-primary, #f8fafc);
}
.dashboard-header {
background: var(--surface-elevated, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
.logo-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-primary, #0ea5e9);
}
.header-nav {
display: flex;
align-items: center;
gap: 1rem;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border-radius: 0.5rem;
transition: all 0.2s;
}
.nav-link:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.nav-link.active {
color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.nav-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.nav-button:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.logout-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
background: transparent;
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.logout-button:hover:not(:disabled) {
color: var(--color-alert, #ef4444);
border-color: var(--color-alert, #ef4444);
}
.logout-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border-subtle, #e2e8f0);
border-top-color: var(--text-secondary, #64748b);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.dashboard-main {
flex: 1;
padding: 1.5rem;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.header-content {
flex-wrap: wrap;
height: auto;
padding: 0.75rem 0;
gap: 0.75rem;
}
.header-nav {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
.dashboard-main {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<script lang="ts">
import type { DemoData } from '$types';
import demoData from '$lib/data/demo-data.json';
import DashboardParent from '$lib/components/organisms/Dashboard/DashboardParent.svelte';
import DashboardTeacher from '$lib/components/organisms/Dashboard/DashboardTeacher.svelte';
import DashboardStudent from '$lib/components/organisms/Dashboard/DashboardStudent.svelte';
import DashboardAdmin from '$lib/components/organisms/Dashboard/DashboardAdmin.svelte';
type UserRole = 'parent' | 'teacher' | 'student' | 'admin' | 'direction';
// For now, default to parent role with demo data
// TODO: Fetch real user profile from /api/me when endpoint is implemented
let userRole = $state<UserRole>('parent');
// Simulated first login detection (in real app, this comes from API)
let isFirstLogin = $state(true);
// Serenity score preference (in real app, this is stored in backend)
let serenityEnabled = $state(true);
// Use demo data for now (no real data available yet)
const hasRealData = false;
// Demo child name for personalized messages
const childName = 'Emma';
function handleToggleSerenity(enabled: boolean) {
serenityEnabled = enabled;
// TODO: POST to /api/me/preferences when backend is ready
console.log('Serenity score preference updated:', enabled);
}
// Cast demo data to proper type
const typedDemoData = demoData as DemoData;
// Allow switching roles for demo purposes
function switchRole(role: UserRole) {
userRole = role;
isFirstLogin = false;
}
</script>
<svelte:head>
<title>Tableau de bord - Classeo</title>
</svelte:head>
<!-- Demo role switcher - TODO: Remove when real authentication is implemented -->
<!-- This will be hidden once we can determine user role from /api/me -->
<div class="demo-controls">
<span class="demo-label">Démo - Changer de rôle :</span>
<button class:active={userRole === 'parent'} onclick={() => switchRole('parent')}>Parent</button>
<button class:active={userRole === 'teacher'} onclick={() => switchRole('teacher')}>Enseignant</button>
<button class:active={userRole === 'student'} onclick={() => switchRole('student')}>Élève</button>
<button class:active={userRole === 'admin'} onclick={() => switchRole('admin')}>Admin</button>
</div>
{#if userRole === 'parent'}
<DashboardParent
demoData={typedDemoData}
{isFirstLogin}
isLoading={false}
{hasRealData}
{serenityEnabled}
{childName}
onToggleSerenity={handleToggleSerenity}
/>
{:else if userRole === 'teacher'}
<DashboardTeacher isLoading={false} {hasRealData} />
{:else if userRole === 'student'}
<DashboardStudent demoData={typedDemoData} isLoading={false} {hasRealData} isMinor={true} />
{:else if userRole === 'admin' || userRole === 'direction'}
<DashboardAdmin
isLoading={false}
{hasRealData}
establishmentName="École Alpha"
/>
{/if}
<style>
.demo-controls {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
background: #fef3c7;
border: 1px solid #fcd34d;
border-radius: 0.5rem;
flex-wrap: wrap;
}
.demo-label {
font-size: 0.875rem;
font-weight: 500;
color: #92400e;
}
.demo-controls button {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.demo-controls button:hover {
background: #f3f4f6;
}
.demo-controls button.active {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
</style>

View File

@@ -111,7 +111,7 @@
if (result.success) { if (result.success) {
// Rediriger vers le dashboard // Rediriger vers le dashboard
goto('/'); goto('/dashboard');
} else if (result.error) { } else if (result.error) {
// Gérer les différents types d'erreur // Gérer les différents types d'erreur
switch (result.error.type) { switch (result.error.type) {

View File

@@ -15,7 +15,7 @@
} }
function goHome() { function goHome() {
goto('/'); goto('/dashboard');
} }
</script> </script>
@@ -26,6 +26,8 @@
<span class="logo-text">Classeo</span> <span class="logo-text">Classeo</span>
</button> </button>
<nav class="header-nav"> <nav class="header-nav">
<a href="/dashboard" class="nav-link">Tableau de bord</a>
<a href="/settings" class="nav-link active">Parametres</a>
<button <button
class="logout-button" class="logout-button"
onclick={handleLogout} onclick={handleLogout}
@@ -33,9 +35,9 @@
> >
{#if isLoggingOut} {#if isLoggingOut}
<span class="spinner"></span> <span class="spinner"></span>
Déconnexion... Deconnexion...
{:else} {:else}
Déconnexion Deconnexion
{/if} {/if}
</button> </button>
</nav> </nav>
@@ -58,7 +60,10 @@
.settings-header { .settings-header {
background: var(--surface-elevated, #fff); background: var(--surface-elevated, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0); border-bottom: 1px solid var(--border-subtle, #e2e8f0);
padding: 0 24px; padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
} }
.header-content { .header-content {
@@ -74,39 +79,59 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
padding: 8px 0; padding: 0.5rem 0;
} }
.logo-text { .logo-text {
font-size: 20px; font-size: 1.25rem;
font-weight: 700; font-weight: 700;
color: var(--accent-primary, hsl(199, 89%, 48%)); color: var(--accent-primary, #0ea5e9);
} }
.header-nav { .header-nav {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 1rem;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border-radius: 0.5rem;
transition: all 0.2s;
}
.nav-link:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.nav-link.active {
color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
} }
.logout-button { .logout-button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 0.5rem;
padding: 8px 16px; padding: 0.5rem 1rem;
font-size: 14px; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #64748b); color: var(--text-secondary, #64748b);
background: transparent; background: transparent;
border: 1px solid var(--border-subtle, #e2e8f0); border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 8px; border-radius: 0.5rem;
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
} }
.logout-button:hover:not(:disabled) { .logout-button:hover:not(:disabled) {
color: var(--color-alert, hsl(0, 72%, 51%)); color: var(--color-alert, #ef4444);
border-color: var(--color-alert, hsl(0, 72%, 51%)); border-color: var(--color-alert, #ef4444);
} }
.logout-button:disabled { .logout-button:disabled {
@@ -132,4 +157,20 @@
transform: rotate(360deg); transform: rotate(360deg);
} }
} }
@media (max-width: 768px) {
.header-content {
flex-wrap: wrap;
height: auto;
padding: 0.75rem 0;
gap: 0.75rem;
}
.header-nav {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
}
</style> </style>

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import demoData from '$lib/data/demo-data.json';
describe('demo-data.json', () => {
describe('serenityScore', () => {
it('should have a value between 0 and 100', () => {
expect(demoData.serenityScore.value).toBeGreaterThanOrEqual(0);
expect(demoData.serenityScore.value).toBeLessThanOrEqual(100);
});
it('should have a valid emoji', () => {
expect(['💚', '🟡', '🔴']).toContain(demoData.serenityScore.emoji);
});
it('should have a valid trend', () => {
expect(['stable', 'up', 'down']).toContain(demoData.serenityScore.trend);
});
it('should have components with scores and labels', () => {
const { components } = demoData.serenityScore;
expect(components).toHaveProperty('notes');
expect(components).toHaveProperty('absences');
expect(components).toHaveProperty('devoirs');
for (const [, component] of Object.entries(components)) {
expect(component).toHaveProperty('score');
expect(component).toHaveProperty('label');
expect(typeof component.score).toBe('number');
expect(typeof component.label).toBe('string');
expect(component.score).toBeGreaterThanOrEqual(0);
expect(component.score).toBeLessThanOrEqual(100);
}
});
});
describe('schedule', () => {
it('should have today array with schedule items', () => {
expect(Array.isArray(demoData.schedule.today)).toBe(true);
expect(demoData.schedule.today.length).toBeGreaterThan(0);
});
it('should have valid schedule items with time, subject, and room', () => {
for (const item of demoData.schedule.today) {
expect(item).toHaveProperty('time');
expect(item).toHaveProperty('subject');
expect(item).toHaveProperty('room');
expect(typeof item.time).toBe('string');
expect(typeof item.subject).toBe('string');
expect(typeof item.room).toBe('string');
}
});
});
describe('grades', () => {
it('should have recent array with grade items', () => {
expect(Array.isArray(demoData.grades.recent)).toBe(true);
});
it('should have valid grade items', () => {
for (const item of demoData.grades.recent) {
expect(item).toHaveProperty('subject');
expect(item).toHaveProperty('value');
expect(item).toHaveProperty('max');
expect(item).toHaveProperty('date');
expect(typeof item.value).toBe('number');
expect(typeof item.max).toBe('number');
}
});
});
describe('homework', () => {
it('should have upcoming array with homework items', () => {
expect(Array.isArray(demoData.homework.upcoming)).toBe(true);
});
it('should have valid homework items', () => {
for (const item of demoData.homework.upcoming) {
expect(item).toHaveProperty('subject');
expect(item).toHaveProperty('title');
expect(item).toHaveProperty('dueDate');
expect(item).toHaveProperty('status');
expect(['pending', 'done', 'late']).toContain(item.status);
}
});
});
});

View File

@@ -0,0 +1,106 @@
import { describe, it, expect } from 'vitest';
import {
calculateSerenityScore,
getSerenityEmoji,
getSerenityLabel,
isValidScore,
getTrendIcon
} from '$lib/features/dashboard/serenity-score';
describe('serenity-score utilities', () => {
describe('calculateSerenityScore', () => {
it('should calculate score correctly with all 100s', () => {
const score = calculateSerenityScore({ notes: 100, absences: 100, devoirs: 100 });
expect(score).toBe(100);
});
it('should calculate score correctly with all 0s', () => {
const score = calculateSerenityScore({ notes: 0, absences: 0, devoirs: 0 });
expect(score).toBe(0);
});
it('should calculate score with weighted components', () => {
// Notes 90 × 0.4 = 36
// Absences 95 × 0.3 = 28.5
// Devoirs 70 × 0.3 = 21
// Total = 85.5, rounded to 86
const score = calculateSerenityScore({ notes: 90, absences: 95, devoirs: 70 });
expect(score).toBe(86);
});
it('should round the score to nearest integer', () => {
const score = calculateSerenityScore({ notes: 50, absences: 50, devoirs: 50 });
expect(score).toBe(50);
expect(Number.isInteger(score)).toBe(true);
});
});
describe('getSerenityEmoji', () => {
it('should return green emoji for scores >= 70', () => {
expect(getSerenityEmoji(70)).toBe('💚');
expect(getSerenityEmoji(85)).toBe('💚');
expect(getSerenityEmoji(100)).toBe('💚');
});
it('should return yellow emoji for scores 40-69', () => {
expect(getSerenityEmoji(40)).toBe('🟡');
expect(getSerenityEmoji(55)).toBe('🟡');
expect(getSerenityEmoji(69)).toBe('🟡');
});
it('should return red emoji for scores < 40', () => {
expect(getSerenityEmoji(0)).toBe('🔴');
expect(getSerenityEmoji(25)).toBe('🔴');
expect(getSerenityEmoji(39)).toBe('🔴');
});
});
describe('getSerenityLabel', () => {
it('should return Excellent for scores >= 70', () => {
expect(getSerenityLabel(70)).toBe('Excellent');
expect(getSerenityLabel(100)).toBe('Excellent');
});
it('should return A surveiller for scores 40-69', () => {
expect(getSerenityLabel(40)).toBe('A surveiller');
expect(getSerenityLabel(69)).toBe('A surveiller');
});
it('should return Attention requise for scores < 40', () => {
expect(getSerenityLabel(0)).toBe('Attention requise');
expect(getSerenityLabel(39)).toBe('Attention requise');
});
});
describe('isValidScore', () => {
it('should return true for valid scores', () => {
expect(isValidScore(0)).toBe(true);
expect(isValidScore(50)).toBe(true);
expect(isValidScore(100)).toBe(true);
});
it('should return false for scores below 0', () => {
expect(isValidScore(-1)).toBe(false);
expect(isValidScore(-100)).toBe(false);
});
it('should return false for scores above 100', () => {
expect(isValidScore(101)).toBe(false);
expect(isValidScore(200)).toBe(false);
});
});
describe('getTrendIcon', () => {
it('should return up arrow for up trend', () => {
expect(getTrendIcon('up')).toBe('↑');
});
it('should return down arrow for down trend', () => {
expect(getTrendIcon('down')).toBe('↓');
});
it('should return right arrow for stable trend', () => {
expect(getTrendIcon('stable')).toBe('→');
});
});
});

View File

@@ -1,23 +0,0 @@
import { render, screen } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import Page from '../../src/routes/+page.svelte';
describe('Home Page', () => {
it('renders the welcome message', () => {
render(Page);
expect(screen.getByRole('heading', { name: 'Bienvenue sur Classeo' })).toBeTruthy();
});
it('renders the description', () => {
render(Page);
expect(screen.getByText('Application de gestion scolaire')).toBeTruthy();
});
it('starts counter at 0', () => {
render(Page);
expect(screen.getByText('Compteur: 0')).toBeTruthy();
});
});