diff --git a/src/app/AppLayout.tsx b/src/app/AppLayout.tsx index 297e83d..f4a4798 100644 --- a/src/app/AppLayout.tsx +++ b/src/app/AppLayout.tsx @@ -77,7 +77,9 @@ export function AppLayout({ children }: AppLayoutProps) { style={{ background: mainBackground }} > setIsMobileMenuOpen(true)} /> -
{children}
+ {/* Pas de padding ni de max-width ici : chaque page gère sa propre + largeur de contenu et son propre padding (cf. HistoriquePage). */} + {children} {/* ── MOBILE — BottomNav fixe ────────────────────────────────── */} diff --git a/src/features/dashboard/pages/DashboardPage.tsx b/src/features/dashboard/pages/DashboardPage.tsx index 11b906d..48e1212 100644 --- a/src/features/dashboard/pages/DashboardPage.tsx +++ b/src/features/dashboard/pages/DashboardPage.tsx @@ -39,7 +39,7 @@ function DashboardSkeleton() { ) } -export function DashboardPage() { +function DashboardContent() { const { user } = useAuth() const { data, isLoading, isError } = usePlan() const queryClient = useQueryClient() @@ -88,3 +88,11 @@ export function DashboardPage() { return } + +export function DashboardPage() { + return ( +
+ +
+ ) +} diff --git a/src/features/historique/__tests__/lib.test.ts b/src/features/historique/__tests__/lib.test.ts new file mode 100644 index 0000000..84da8d9 --- /dev/null +++ b/src/features/historique/__tests__/lib.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect } from 'vitest' +import { + applyFilters, + computeStats, + computeTrend, + formatShortDate, + formatTaskLabel, + nclcChipVariant, +} from '../lib/historique' +import type { SimulationListItem } from '@/entities/production/types' + +const NOW = new Date('2026-04-25T12:00:00Z') + +function item( + overrides: Partial & { id: string; created_at: string }, +): SimulationListItem { + return { + id: overrides.id, + tache: overrides.tache ?? 'EE_T1', + mode: overrides.mode ?? 'entrainement', + score: overrides.score ?? null, + nclc: overrides.nclc ?? null, + nclc_cible: overrides.nclc_cible ?? null, + created_at: overrides.created_at, + } +} + +describe('applyFilters', () => { + const items: SimulationListItem[] = [ + item({ id: 'a', tache: 'EE_T1', created_at: '2026-04-22T10:00:00Z', score: 14 }), + item({ id: 'b', tache: 'EE_T2', created_at: '2026-04-10T10:00:00Z', score: 12 }), + item({ id: 'c', tache: 'EO_T1', created_at: '2026-02-15T10:00:00Z', score: 16 }), + item({ id: 'd', tache: 'EO_T3', created_at: '2025-12-01T10:00:00Z', score: 10 }), + ] + + it('task=all + period=all → tous les items', () => { + expect(applyFilters(items, { task: 'all', period: 'all' }, NOW).map((i) => i.id)).toEqual([ + 'a', + 'b', + 'c', + 'd', + ]) + }) + + it('filtre par tâche', () => { + expect(applyFilters(items, { task: 'EE_T1', period: 'all' }, NOW).map((i) => i.id)).toEqual([ + 'a', + ]) + }) + + it("period=this-month garde uniquement les items d'avril 2026", () => { + expect( + applyFilters(items, { task: 'all', period: 'this-month' }, NOW).map((i) => i.id), + ).toEqual(['a', 'b']) + }) + + it('period=3-months exclut les items > 90 jours', () => { + expect(applyFilters(items, { task: 'all', period: '3-months' }, NOW).map((i) => i.id)).toEqual([ + 'a', + 'b', + 'c', + ]) + }) + + it('combine tâche + période', () => { + expect( + applyFilters(items, { task: 'EE_T2', period: 'this-month' }, NOW).map((i) => i.id), + ).toEqual(['b']) + }) +}) + +describe('computeStats', () => { + it('dataset vide → all null', () => { + expect(computeStats([], NOW)).toEqual({ + total: 0, + thisMonth: 0, + average: null, + best: null, + }) + }) + + it('ignore les scores null pour average + best', () => { + const items = [ + item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 12 }), + item({ id: 'b', created_at: '2026-04-21T10:00:00Z', score: null }), + item({ id: 'c', created_at: '2026-04-22T10:00:00Z', score: 18 }), + ] + const s = computeStats(items, NOW) + expect(s.total).toBe(3) + expect(s.thisMonth).toBe(3) + expect(s.average).toBe(15) + expect(s.best?.score).toBe(18) + expect(s.best?.created_at).toBe('2026-04-22T10:00:00Z') + }) + + it('thisMonth ne compte que le mois courant', () => { + const items = [ + item({ id: 'a', created_at: '2026-04-22T10:00:00Z', score: 14 }), + item({ id: 'b', created_at: '2026-03-22T10:00:00Z', score: 14 }), + ] + expect(computeStats(items, NOW).thisMonth).toBe(1) + }) +}) + +describe('computeTrend', () => { + it('retourne null si fenêtre récente vide', () => { + const items = [item({ id: 'a', created_at: '2026-02-15T10:00:00Z', score: 10 })] + expect(computeTrend(items, NOW)).toBeNull() + }) + + it('retourne null si fenêtre précédente vide', () => { + const items = [item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 14 })] + expect(computeTrend(items, NOW)).toBeNull() + }) + + it('détecte une tendance up', () => { + const items = [ + // récents (0–30j) : moyenne 15 + item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 16 }), + item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }), + // précédents (30–60j) : moyenne 12 + item({ id: 'c', created_at: '2026-03-20T10:00:00Z', score: 12 }), + item({ id: 'd', created_at: '2026-03-10T10:00:00Z', score: 12 }), + ] + const t = computeTrend(items, NOW) + expect(t?.direction).toBe('up') + expect(t?.delta).toBeCloseTo(3, 5) + }) + + it('détecte une tendance down', () => { + const items = [ + item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: 10 }), + item({ id: 'b', created_at: '2026-03-15T10:00:00Z', score: 14 }), + ] + const t = computeTrend(items, NOW) + expect(t?.direction).toBe('down') + expect(t?.delta).toBeCloseTo(4, 5) + }) + + it('ignore les scores null', () => { + const items = [ + item({ id: 'a', created_at: '2026-04-20T10:00:00Z', score: null }), + item({ id: 'b', created_at: '2026-04-10T10:00:00Z', score: 14 }), + item({ id: 'c', created_at: '2026-03-15T10:00:00Z', score: 12 }), + ] + const t = computeTrend(items, NOW) + expect(t?.direction).toBe('up') + expect(t?.delta).toBeCloseTo(2, 5) + }) +}) + +describe('formatShortDate', () => { + it('formate "22 avr." en fr-FR', () => { + expect(formatShortDate('2026-04-22T10:00:00Z')).toMatch(/22 avr/) + }) + + it('retourne chaîne vide pour ISO invalide', () => { + expect(formatShortDate('not-a-date')).toBe('') + }) +}) + +describe('formatTaskLabel', () => { + it('entraînement → "EE · Tâche 3"', () => { + expect(formatTaskLabel({ tache: 'EE_T3', mode: 'entrainement' })).toBe('EE · Tâche 3') + }) + + it('examen EE → "Examen blanc EE"', () => { + expect(formatTaskLabel({ tache: 'EE_T1', mode: 'examen' })).toBe('Examen blanc EE') + }) + + it('examen EO → "Examen blanc EO"', () => { + expect(formatTaskLabel({ tache: 'EO_T3', mode: 'examen' })).toBe('Examen blanc EO') + }) +}) + +describe('nclcChipVariant', () => { + it('≥ 9 → ok', () => { + expect(nclcChipVariant(9)).toBe('ok') + expect(nclcChipVariant(12)).toBe('ok') + }) + it('7-8 → warn', () => { + expect(nclcChipVariant(7)).toBe('warn') + expect(nclcChipVariant(8)).toBe('warn') + }) + it('≤ 6 → err', () => { + expect(nclcChipVariant(6)).toBe('err') + expect(nclcChipVariant(0)).toBe('err') + }) +}) diff --git a/src/features/historique/components/HistoriqueFilters.tsx b/src/features/historique/components/HistoriqueFilters.tsx new file mode 100644 index 0000000..4e38033 --- /dev/null +++ b/src/features/historique/components/HistoriqueFilters.tsx @@ -0,0 +1,129 @@ +/** + * Filtres de la page /historique — refonte Sprint 4.7 + correction theming. + * + * Dropdowns custom (div + état ouvert/fermé) — zéro lib externe — pour + * garantir la lisibilité en dark/light. Tokens DA Charcoal (Règle L). + * + * Accessibilité minimale : button aria-haspopup, fermeture sur clic + * extérieur ou Escape, options atteignables au clic. + */ + +import { useEffect, useRef, useState } from 'react' +import { ChevronDown } from 'lucide-react' +import type { PeriodFilter, TaskFilter } from '../lib/historique' + +interface Props { + task: TaskFilter + period: PeriodFilter + onTaskChange: (task: TaskFilter) => void + onPeriodChange: (period: PeriodFilter) => void +} + +const TASK_OPTIONS: { value: TaskFilter; label: string }[] = [ + { value: 'all', label: 'Toutes les tâches' }, + { value: 'EE_T1', label: 'EE T1' }, + { value: 'EE_T2', label: 'EE T2' }, + { value: 'EE_T3', label: 'EE T3' }, + { value: 'EO_T1', label: 'EO T1' }, + { value: 'EO_T3', label: 'EO T3' }, +] + +const PERIOD_OPTIONS: { value: PeriodFilter; label: string }[] = [ + { value: 'this-month', label: 'Ce mois' }, + { value: '3-months', label: '3 mois' }, + { value: 'all', label: 'Tout' }, +] + +interface DropdownProps { + value: T + options: { value: T; label: string }[] + onChange: (value: T) => void + ariaLabel: string +} + +function Dropdown({ value, options, onChange, ariaLabel }: DropdownProps) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + function onDocClick(e: MouseEvent) { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false) + } + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('mousedown', onDocClick) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDocClick) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + const selected = options.find((o) => o.value === value) ?? options[0] + + return ( +
+ + + {open && ( +
    + {options.map((opt) => { + const isActive = opt.value === value + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} + +export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) { + return ( +
+ + +
+ ) +} diff --git a/src/features/historique/components/HistoriqueStats.tsx b/src/features/historique/components/HistoriqueStats.tsx new file mode 100644 index 0000000..52621b2 --- /dev/null +++ b/src/features/historique/components/HistoriqueStats.tsx @@ -0,0 +1,118 @@ +/** + * 3 cartes métriques en haut de /historique — Sprint 4.7. + * + * Total simulations / Score moyen / Meilleur score. Recalculées à chaque + * changement de filtres (les filtres sont appliqués en amont par la page). + * + * Règle L : tokens DA Charcoal exclusivement. + * Règle H : purement présentationnel. + */ + +import { + formatShortDate, + formatTaskLabel, + type HistoriqueStats, + type Trend, +} from '../lib/historique' + +interface Props { + stats: HistoriqueStats + trend: Trend | null +} + +const NUMBER_FR = new Intl.NumberFormat('fr-FR', { + minimumFractionDigits: 1, + maximumFractionDigits: 1, +}) + +function Label({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} + +function Value({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function Unit({ children }: { children: React.ReactNode }) { + return {children} +} + +function Footer({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function TrendChip({ trend }: { trend: Trend }) { + const isUp = trend.direction === 'up' + const sign = isUp ? '+' : '-' + const label = `${sign}${NUMBER_FR.format(trend.delta)} en 30j` + const colorClasses = isUp + ? 'bg-success-soft text-success border-success/30' + : 'bg-danger-soft text-danger border-danger/30' + return ( + + + {label} + + ) +} + +export function HistoriqueStatsCards({ stats, trend }: Props) { + return ( +
+
+ +
+ {stats.total} +
+
dont {stats.thisMonth} ce mois
+
+ +
+ +
+ {stats.average !== null ? ( + <> + {NUMBER_FR.format(stats.average)} + /20 + + ) : ( + + )} +
+ {trend ? :
} +
+ +
+ +
+ {stats.best !== null ? ( + <> + {stats.best.score} + /20 + + ) : ( + + )} +
+ {stats.best !== null ? ( +
+ {formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '} + {formatShortDate(stats.best.created_at)} +
+ ) : ( +
+ )} +
+
+ ) +} diff --git a/src/features/historique/components/SimulationListItem.tsx b/src/features/historique/components/SimulationListItem.tsx index e62a3a7..88d86b5 100644 --- a/src/features/historique/components/SimulationListItem.tsx +++ b/src/features/historique/components/SimulationListItem.tsx @@ -1,59 +1,65 @@ /** - * SimulationListItem — Sprint 3.7. + * Item d'une ligne de la liste /historique — réécrit Sprint 4.7 selon maquette. * - * Carte item de la page /historique. Clic → /rapport/:id (RapportPage gère le - * cas `rapport === null` en redirigeant vers /simulation/ee — FTD-21). + * Layout flex : Date · Libellé · Badge NCLC · Score · Chevron. + * Couleur du badge NCLC selon seuil (cf. `nclcChipVariant`). * - * Règle L : tokens Direction H exclusivement. - * Règle H : purement présentationnel — aucune logique plan ici. + * Règle L : tokens DA Charcoal exclusivement. + * Règle H : purement présentationnel. */ +import { ChevronRight } from 'lucide-react' import { Link } from 'react-router-dom' -import { Badge } from '@/shared/ui/Badge' -import { formatTache } from '@/entities/production/lib' -import { formatRelativeDate } from '@/shared/lib/date' import type { SimulationListItem as Item } from '@/entities/production/types' +import { formatShortDate, formatTaskLabel, nclcChipVariant } from '../lib/historique' interface Props { item: Item + isLast: boolean } -export function SimulationListItem({ item }: Props) { +const CHIP_BASE = + 'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide' + +const CHIP_OK = 'bg-success-soft text-success border-success/30' +const CHIP_WARN = 'bg-warning-soft text-warning border-warning/30' +const CHIP_ERR = 'bg-danger-soft text-danger border-danger/30' +const CHIP_NEUTRAL = 'bg-surface text-ink-secondary border-border' + +function NclcBadge({ nclc }: { nclc: number }) { + const variant = nclcChipVariant(nclc) + const cls = variant === 'ok' ? CHIP_OK : variant === 'warn' ? CHIP_WARN : CHIP_ERR + return NCLC {nclc} +} + +export function SimulationListItem({ item, isLast }: Props) { const hasScore = item.score !== null && item.nclc !== null - const isExam = item.mode === 'examen' + const borderClass = isLast ? '' : 'border-b border-border' return ( -
-
-
- - {formatTache(item.tache)} - - {isExam && Examen} - {!hasScore && En cours} -
-

{formatRelativeDate(item.created_at)}

-
+ + {formatShortDate(item.created_at)} + - {hasScore ? ( -
-

- {item.score} - /20 -

-

- NCLC {item.nclc} - {item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''} -

-
- ) : ( -
Score à venir
- )} -
+ + {formatTaskLabel(item)} + + + {hasScore && item.nclc !== null ? ( + + ) : ( + En cours + )} + + + {hasScore ? `${item.score}/20` : '—/20'} + + +