feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)
Inclut le retrait du padding de AppLayout et le wrapper standardisé (mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur 11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour laisser chaque page gérer son max-width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8bae9520c
commit
3ce91aaa7b
20 changed files with 1417 additions and 874 deletions
|
|
@ -77,7 +77,9 @@ export function AppLayout({ children }: AppLayoutProps) {
|
|||
style={{ background: mainBackground }}
|
||||
>
|
||||
<Topbar onMobileMenuOpen={() => setIsMobileMenuOpen(true)} />
|
||||
<div className="mx-auto max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">{children}</div>
|
||||
{/* Pas de padding ni de max-width ici : chaque page gère sa propre
|
||||
largeur de contenu et son propre padding (cf. HistoriquePage). */}
|
||||
{children}
|
||||
</main>
|
||||
|
||||
{/* ── MOBILE — BottomNav fixe ────────────────────────────────── */}
|
||||
|
|
|
|||
|
|
@ -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 <DashboardStandardView displayName={displayName} simulationsUsed={data.simulations_used} />
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<DashboardContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
189
src/features/historique/__tests__/lib.test.ts
Normal file
189
src/features/historique/__tests__/lib.test.ts
Normal file
|
|
@ -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<SimulationListItem> & { 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')
|
||||
})
|
||||
})
|
||||
129
src/features/historique/components/HistoriqueFilters.tsx
Normal file
129
src/features/historique/components/HistoriqueFilters.tsx
Normal file
|
|
@ -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<T extends string> {
|
||||
value: T
|
||||
options: { value: T; label: string }[]
|
||||
onChange: (value: T) => void
|
||||
ariaLabel: string
|
||||
}
|
||||
|
||||
function Dropdown<T extends string>({ value, options, onChange, ariaLabel }: DropdownProps<T>) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={rootRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border bg-surface px-[14px] py-[7px] text-[12.5px] font-semibold text-ink-primary hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||
>
|
||||
<span>{selected.label}</span>
|
||||
<ChevronDown className="size-3.5 text-ink-tertiary" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<ul
|
||||
role="listbox"
|
||||
className="absolute right-0 z-10 mt-1 min-w-full overflow-hidden rounded-lg border border-border bg-surface-solid shadow-card"
|
||||
>
|
||||
{options.map((opt) => {
|
||||
const isActive = opt.value === value
|
||||
return (
|
||||
<li key={opt.value}>
|
||||
<button
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onClick={() => {
|
||||
onChange(opt.value)
|
||||
setOpen(false)
|
||||
}}
|
||||
className={`block w-full whitespace-nowrap px-[14px] py-2 text-left text-[12.5px] font-medium hover:bg-surface-hover focus-visible:bg-surface-hover focus-visible:outline-none ${
|
||||
isActive ? 'text-brand-text' : 'text-ink-primary'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function HistoriqueFilters({ task, period, onTaskChange, onPeriodChange }: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Dropdown
|
||||
value={task}
|
||||
options={TASK_OPTIONS}
|
||||
onChange={onTaskChange}
|
||||
ariaLabel="Filtrer par tâche"
|
||||
/>
|
||||
<Dropdown
|
||||
value={period}
|
||||
options={PERIOD_OPTIONS}
|
||||
onChange={onPeriodChange}
|
||||
ariaLabel="Filtrer par période"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/features/historique/components/HistoriqueStats.tsx
Normal file
118
src/features/historique/components/HistoriqueStats.tsx
Normal file
|
|
@ -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 (
|
||||
<p className="text-[11.5px] font-medium uppercase tracking-[0.04em] text-ink-secondary">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
function Value({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="text-[34px] font-bold leading-none tracking-[-0.03em] tabular-nums text-ink-primary">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Unit({ children }: { children: React.ReactNode }) {
|
||||
return <span className="ml-[3px] text-[15px] font-medium text-ink-tertiary">{children}</span>
|
||||
}
|
||||
|
||||
function Footer({ children }: { children: React.ReactNode }) {
|
||||
return <p className="mt-2 text-[11.5px] text-ink-tertiary">{children}</p>
|
||||
}
|
||||
|
||||
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 (
|
||||
<span
|
||||
className={`mt-2 inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11.5px] font-semibold ${colorClasses}`}
|
||||
>
|
||||
<span aria-hidden="true">{isUp ? '↑' : '↓'}</span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function HistoriqueStatsCards({ stats, trend }: Props) {
|
||||
return (
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
|
||||
<Label>Total simulations</Label>
|
||||
<div className="mt-2 flex items-baseline">
|
||||
<Value>{stats.total}</Value>
|
||||
</div>
|
||||
<Footer>dont {stats.thisMonth} ce mois</Footer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
|
||||
<Label>Score moyen</Label>
|
||||
<div className="mt-2 flex items-baseline">
|
||||
{stats.average !== null ? (
|
||||
<>
|
||||
<Value>{NUMBER_FR.format(stats.average)}</Value>
|
||||
<Unit>/20</Unit>
|
||||
</>
|
||||
) : (
|
||||
<Value>—</Value>
|
||||
)}
|
||||
</div>
|
||||
{trend ? <TrendChip trend={trend} /> : <Footer>—</Footer>}
|
||||
</div>
|
||||
|
||||
<div className="rounded-[12px] border border-border bg-surface p-4 px-[18px] shadow-card">
|
||||
<Label>Meilleur score</Label>
|
||||
<div className="mt-2 flex items-baseline">
|
||||
{stats.best !== null ? (
|
||||
<>
|
||||
<Value>{stats.best.score}</Value>
|
||||
<Unit>/20</Unit>
|
||||
</>
|
||||
) : (
|
||||
<Value>—</Value>
|
||||
)}
|
||||
</div>
|
||||
{stats.best !== null ? (
|
||||
<Footer>
|
||||
{formatTaskLabel({ tache: stats.best.tache, mode: 'entrainement' })} ·{' '}
|
||||
{formatShortDate(stats.best.created_at)}
|
||||
</Footer>
|
||||
) : (
|
||||
<Footer>—</Footer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 <span className={`${CHIP_BASE} ${cls}`}>NCLC {nclc}</span>
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link
|
||||
to={`/rapport/${item.id}`}
|
||||
className="block rounded-lg border border-border bg-surface p-4 shadow-card transition-colors duration-150 hover:border-brand hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus"
|
||||
className={`flex items-center gap-[14px] px-4 py-[14px] transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:shadow-focus ${borderClass}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink-primary">
|
||||
{formatTache(item.tache)}
|
||||
<span className="w-[68px] shrink-0 text-[11.5px] tabular-nums text-ink-tertiary">
|
||||
{formatShortDate(item.created_at)}
|
||||
</span>
|
||||
{isExam && <Badge variant="nclc">Examen</Badge>}
|
||||
{!hasScore && <Badge variant="neutral">En cours</Badge>}
|
||||
</div>
|
||||
<p className="text-xs text-ink-secondary">{formatRelativeDate(item.created_at)}</p>
|
||||
</div>
|
||||
|
||||
{hasScore ? (
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="tabular-nums text-ink-primary">
|
||||
<span className="text-xl font-bold">{item.score}</span>
|
||||
<span className="text-sm font-medium text-ink-secondary">/20</span>
|
||||
</p>
|
||||
<p className="text-xs text-ink-secondary tabular-nums">
|
||||
NCLC {item.nclc}
|
||||
{item.nclc_cible ? ` / cible ${item.nclc_cible}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium text-ink-primary">
|
||||
{formatTaskLabel(item)}
|
||||
</span>
|
||||
|
||||
{hasScore && item.nclc !== null ? (
|
||||
<NclcBadge nclc={item.nclc} />
|
||||
) : (
|
||||
<div className="shrink-0 text-right text-xs text-ink-secondary">Score à venir</div>
|
||||
<span className={`${CHIP_BASE} ${CHIP_NEUTRAL}`}>En cours</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="min-w-[56px] text-right text-[16px] font-semibold tracking-[-0.02em] tabular-nums text-ink-primary">
|
||||
{hasScore ? `${item.score}/20` : '—/20'}
|
||||
</span>
|
||||
|
||||
<ChevronRight className="size-[14px] shrink-0 text-ink-tertiary" aria-hidden="true" />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
/**
|
||||
* SimulationsList — Sprint 3.7.
|
||||
* SimulationsList — refonte Sprint 4.7.
|
||||
*
|
||||
* Orchestre :
|
||||
* - Loading / error / empty state
|
||||
* - Gating plan : Free → aperçu flouté + upgrade ; Standard+ → liste
|
||||
* - Pagination Précédent/Suivant (MVP)
|
||||
* - Reçoit directement `items: SimulationListItem[]` (filtrés en amont par la
|
||||
* page) au lieu d'une réponse paginée. La pagination Précédent/Suivant a
|
||||
* été supprimée au profit du filtrage local (cf. HistoriquePage).
|
||||
* - Conserve le gating Free (aperçu flouté + CTA upgrade — Règle D).
|
||||
* - Distingue état vide global (« aucune simulation ») vs filtré
|
||||
* (« aucun résultat pour ces filtres »).
|
||||
*
|
||||
* Règle D : passe par `hasAccess(plan, 'dashboard')` — jamais de `plan === 'free'`.
|
||||
* Règle L : tokens Direction H exclusivement.
|
||||
* Règle L : tokens DA Charcoal exclusivement.
|
||||
*/
|
||||
|
||||
import { Lock } from 'lucide-react'
|
||||
|
|
@ -15,27 +16,24 @@ import { Link } from 'react-router-dom'
|
|||
import { Card } from '@/shared/ui/Card'
|
||||
import { Button } from '@/shared/ui/Button'
|
||||
import { hasAccess, type Plan } from '@/entities/user/lib'
|
||||
import type { SimulationsListResponse } from '@/entities/production/types'
|
||||
import type { SimulationListItem as Item } from '@/entities/production/types'
|
||||
import { SimulationListItem } from './SimulationListItem'
|
||||
|
||||
interface Props {
|
||||
plan: Plan
|
||||
data: SimulationsListResponse | undefined
|
||||
items: Item[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
page: number
|
||||
limit: number
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
/** True si au moins un filtre non-`all` est actif — distingue empty filtré vs global. */
|
||||
isFiltered: boolean
|
||||
onUpgrade: () => void
|
||||
}
|
||||
|
||||
// Floutage local (dupliqué du pattern RapportPage — à extraire en shared/ si répété ailleurs).
|
||||
const PLACEHOLDER_WIDTHS = ['w-3/4', 'w-full', 'w-3/5', 'w-4/5'] as const
|
||||
|
||||
function BlurredPreview({ onUpgrade }: { onUpgrade: () => void }) {
|
||||
return (
|
||||
<div className="relative min-h-[240px] overflow-hidden rounded-lg border border-border bg-surface">
|
||||
<div className="relative min-h-[240px] overflow-hidden rounded-[12px] border border-border bg-surface">
|
||||
<div className="space-y-3 p-4 opacity-25 blur-sm" aria-hidden="true">
|
||||
{PLACEHOLDER_WIDTHS.map((w, i) => (
|
||||
<div key={i} className={`h-16 rounded bg-surface-hover ${w}`} />
|
||||
|
|
@ -56,7 +54,7 @@ function ListSkeleton() {
|
|||
return (
|
||||
<div className="space-y-3" aria-busy="true" aria-label="Chargement de l'historique…">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-20 animate-pulse rounded-lg bg-surface" />
|
||||
<div key={i} className="h-14 animate-pulse rounded-lg bg-surface" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -80,6 +78,17 @@ function EmptyState() {
|
|||
)
|
||||
}
|
||||
|
||||
function EmptyFilteredState() {
|
||||
return (
|
||||
<Card variant="default" className="p-6 text-center">
|
||||
<p className="text-sm text-ink-primary">Aucune simulation ne correspond à ces filtres.</p>
|
||||
<p className="mt-1 text-xs text-ink-secondary">
|
||||
Essayez d'élargir la période ou de changer la tâche sélectionnée.
|
||||
</p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorState() {
|
||||
return (
|
||||
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||
|
|
@ -90,58 +99,27 @@ function ErrorState() {
|
|||
)
|
||||
}
|
||||
|
||||
export function SimulationsList({
|
||||
plan,
|
||||
data,
|
||||
isLoading,
|
||||
isError,
|
||||
page,
|
||||
limit,
|
||||
onPrev,
|
||||
onNext,
|
||||
onUpgrade,
|
||||
}: Props) {
|
||||
// Gating plan — Free voit uniquement l'aperçu flouté (Règle D).
|
||||
export function SimulationsList({ plan, items, isLoading, isError, isFiltered, onUpgrade }: Props) {
|
||||
if (!hasAccess(plan, 'dashboard')) {
|
||||
return <BlurredPreview onUpgrade={onUpgrade} />
|
||||
}
|
||||
|
||||
if (isError) return <ErrorState />
|
||||
if (isLoading && !data) return <ListSkeleton />
|
||||
if (!data) return null
|
||||
if (isLoading) return <ListSkeleton />
|
||||
|
||||
if (data.data.length === 0) return <EmptyState />
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(data.pagination.total / limit))
|
||||
const isFirst = page <= 1
|
||||
const isLast = page >= totalPages
|
||||
if (items.length === 0) {
|
||||
return isFiltered ? <EmptyFilteredState /> : <EmptyState />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<ul className="space-y-3">
|
||||
{data.data.map((item) => (
|
||||
<li key={item.id}>
|
||||
<SimulationListItem item={item} />
|
||||
<div className="overflow-hidden rounded-[12px] border border-border bg-surface shadow-card">
|
||||
<ul>
|
||||
{items.map((it, i) => (
|
||||
<li key={it.id}>
|
||||
<SimulationListItem item={it} isLast={i === items.length - 1} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<nav
|
||||
aria-label="Pagination de l'historique"
|
||||
className="flex items-center justify-between gap-3 pt-2"
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={onPrev} disabled={isFirst}>
|
||||
Précédent
|
||||
</Button>
|
||||
<p className="text-xs text-ink-secondary tabular-nums" aria-live="polite">
|
||||
Page {page} sur {totalPages} — {data.pagination.total} simulations
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={onNext} disabled={isLast}>
|
||||
Suivant
|
||||
</Button>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
/**
|
||||
* Tests — SimulationsList (Sprint 3.7).
|
||||
* Tests SimulationsList — refonte Sprint 4.7.
|
||||
*
|
||||
* Couvre :
|
||||
* - État vide (Standard+)
|
||||
* - Items rendus (Standard+)
|
||||
* - Gating Free : aperçu flouté + bouton upgrade
|
||||
* - Pagination : Précédent désactivé sur page 1, Suivant désactivé sur dernière page
|
||||
* - Clic upgrade → onUpgrade appelé
|
||||
* Couvre : gating Free, état vide global, état vide filtré, items rendus,
|
||||
* isError, isLoading. La pagination a été retirée au Sprint 4.7.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||
|
|
@ -14,7 +10,7 @@ import { render, screen, cleanup } from '@testing-library/react'
|
|||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { SimulationsList } from '../SimulationsList'
|
||||
import type { SimulationsListResponse } from '@/entities/production/types'
|
||||
import type { SimulationListItem } from '@/entities/production/types'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
|
|
@ -22,13 +18,7 @@ function renderWithRouter(ui: React.ReactNode) {
|
|||
return render(<MemoryRouter>{ui}</MemoryRouter>)
|
||||
}
|
||||
|
||||
const EMPTY: SimulationsListResponse = {
|
||||
data: [],
|
||||
pagination: { page: 1, limit: 20, total: 0 },
|
||||
}
|
||||
|
||||
const THREE_ITEMS: SimulationsListResponse = {
|
||||
data: [
|
||||
const ITEMS: SimulationListItem[] = [
|
||||
{
|
||||
id: 'p1',
|
||||
tache: 'EE_T1',
|
||||
|
|
@ -56,32 +46,24 @@ const THREE_ITEMS: SimulationsListResponse = {
|
|||
nclc_cible: null,
|
||||
created_at: '2026-04-22T08:00:00Z',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 3 },
|
||||
}
|
||||
]
|
||||
|
||||
const NOOP = () => {}
|
||||
|
||||
describe('SimulationsList — plan Free (gating)', () => {
|
||||
it('affiche l\'aperçu flouté et le bouton "Passer en Standard"', () => {
|
||||
describe('SimulationsList — gating Free', () => {
|
||||
it("affiche l'aperçu flouté pour le plan Free", () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="free"
|
||||
data={THREE_ITEMS}
|
||||
items={ITEMS}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/historique disponible en standard/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /passer en standard/i })).toBeInTheDocument()
|
||||
// Les items NE doivent PAS être accessibles à Free
|
||||
expect(screen.queryByText('Expression Écrite — Tâche 1')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText(/EE · Tâche 1/i)).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clic sur "Passer en Standard" appelle onUpgrade', async () => {
|
||||
|
|
@ -90,221 +72,115 @@ describe('SimulationsList — plan Free (gating)', () => {
|
|||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="free"
|
||||
data={THREE_ITEMS}
|
||||
items={ITEMS}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={onUpgrade}
|
||||
/>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /passer en standard/i }))
|
||||
expect(onUpgrade).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SimulationsList — plan Standard', () => {
|
||||
it('affiche l\'état vide avec le CTA "Démarrer une simulation"', () => {
|
||||
it('affiche l\'état vide global avec CTA "Démarrer une simulation"', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={EMPTY}
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/aucune simulation/i)).toBeInTheDocument()
|
||||
expect(screen.getByText(/aucune simulation pour le moment/i)).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: /démarrer une simulation/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/simulation/ee',
|
||||
)
|
||||
})
|
||||
|
||||
it('rend les items avec score, NCLC et badge "Examen" quand mode=examen', () => {
|
||||
it('affiche un état vide spécifique quand des filtres sont actifs', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={THREE_ITEMS}
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={true}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
expect(screen.getByText(/aucune simulation ne correspond à ces filtres/i)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('link', { name: /démarrer une simulation/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// 3 items rendus
|
||||
it('rend les items avec libellé, score et badges', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
items={ITEMS}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
const links = screen.getAllByRole('link')
|
||||
expect(links).toHaveLength(3)
|
||||
// Item p1 : score 14, NCLC 9 / cible 9
|
||||
expect(screen.getByText('14')).toBeInTheDocument()
|
||||
expect(screen.getByText(/NCLC 9 \/ cible 9/)).toBeInTheDocument()
|
||||
// Item p2 (examen) : badge Examen
|
||||
expect(screen.getByText('Examen')).toBeInTheDocument()
|
||||
// Item p3 (score null) : badge "En cours"
|
||||
expect(screen.getByText('En cours')).toBeInTheDocument()
|
||||
expect(screen.getByText('EE · Tâche 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('Examen blanc EE')).toBeInTheDocument()
|
||||
expect(screen.getByText('14/20')).toBeInTheDocument()
|
||||
expect(screen.getByText('NCLC 9')).toBeInTheDocument()
|
||||
expect(screen.getByText(/En cours/i)).toBeInTheDocument()
|
||||
expect(screen.getByText('—/20')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('chaque item pointe vers /rapport/:id', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={THREE_ITEMS}
|
||||
items={ITEMS}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('link', { name: /expression écrite.*tâche 1/i })).toHaveAttribute(
|
||||
'href',
|
||||
'/rapport/p1',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SimulationsList — pagination', () => {
|
||||
it('un seul page (total ≤ limit) : pas de nav de pagination', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={THREE_ITEMS}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('navigation', { name: /pagination/i })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('page 1 sur plusieurs : Précédent désactivé, Suivant actif', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onNext = vi.fn()
|
||||
const multi: SimulationsListResponse = {
|
||||
data: THREE_ITEMS.data,
|
||||
pagination: { page: 1, limit: 20, total: 50 },
|
||||
}
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={multi}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={onNext}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /précédent/i })).toBeDisabled()
|
||||
const suivant = screen.getByRole('button', { name: /suivant/i })
|
||||
expect(suivant).toBeEnabled()
|
||||
await user.click(suivant)
|
||||
expect(onNext).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dernière page : Suivant désactivé', () => {
|
||||
const last: SimulationsListResponse = {
|
||||
data: THREE_ITEMS.data,
|
||||
pagination: { page: 3, limit: 20, total: 50 }, // 50 / 20 = 3 pages
|
||||
}
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={last}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={3}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: /suivant/i })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: /précédent/i })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('affiche le compteur "Page X sur Y — Z simulations"', () => {
|
||||
const multi: SimulationsListResponse = {
|
||||
data: THREE_ITEMS.data,
|
||||
pagination: { page: 2, limit: 20, total: 50 },
|
||||
}
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={multi}
|
||||
isLoading={false}
|
||||
isError={false}
|
||||
page={2}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText(/Page 2 sur 3 — 50 simulations/i)).toBeInTheDocument()
|
||||
const link = screen.getAllByRole('link')[0]
|
||||
expect(link).toHaveAttribute('href', '/rapport/p1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('SimulationsList — états transverses', () => {
|
||||
it("isError → affiche le callout d'erreur", () => {
|
||||
it("isError → callout d'erreur", () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={undefined}
|
||||
items={[]}
|
||||
isLoading={false}
|
||||
isError={true}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i)
|
||||
})
|
||||
|
||||
it('isLoading + pas de data → squelettes', () => {
|
||||
it('isLoading → squelettes', () => {
|
||||
renderWithRouter(
|
||||
<SimulationsList
|
||||
plan="standard"
|
||||
data={undefined}
|
||||
items={[]}
|
||||
isLoading={true}
|
||||
isError={false}
|
||||
page={1}
|
||||
limit={20}
|
||||
onPrev={NOOP}
|
||||
onNext={NOOP}
|
||||
isFiltered={false}
|
||||
onUpgrade={NOOP}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
158
src/features/historique/lib/historique.ts
Normal file
158
src/features/historique/lib/historique.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
/**
|
||||
* Logique pure de la page /historique — Sprint 4.7.
|
||||
*
|
||||
* Toutes les fonctions ici sont déterministes et acceptent `now` injecté
|
||||
* pour permettre des tests reproductibles. Aucune dépendance React, aucune
|
||||
* I/O — Règle H respectée (ces helpers pourraient vivre en `entities/`,
|
||||
* mais ils sont 100 % spécifiques à la page historique → restent ici).
|
||||
*
|
||||
* Filtrage côté frontend uniquement : les 50 simulations les plus récentes
|
||||
* sont chargées en une fois (cf. HistoriquePage), puis filtrées localement.
|
||||
*/
|
||||
|
||||
import type { SimulationListItem, Tache } from '@/entities/production/types'
|
||||
|
||||
export type TaskFilter = 'all' | Tache
|
||||
export type PeriodFilter = 'all' | 'this-month' | '3-months'
|
||||
|
||||
export interface FiltersState {
|
||||
task: TaskFilter
|
||||
period: PeriodFilter
|
||||
}
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
function isInPeriod(iso: string, period: PeriodFilter, now: Date): boolean {
|
||||
if (period === 'all') return true
|
||||
const d = new Date(iso)
|
||||
if (!Number.isFinite(d.getTime())) return false
|
||||
|
||||
if (period === 'this-month') {
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
|
||||
}
|
||||
// 3-months : 90 jours glissants
|
||||
return now.getTime() - d.getTime() <= 90 * DAY_MS
|
||||
}
|
||||
|
||||
export function applyFilters(
|
||||
items: SimulationListItem[],
|
||||
{ task, period }: FiltersState,
|
||||
now: Date,
|
||||
): SimulationListItem[] {
|
||||
return items.filter((it) => {
|
||||
if (task !== 'all' && it.tache !== task) return false
|
||||
if (!isInPeriod(it.created_at, period, now)) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export interface HistoriqueStats {
|
||||
total: number
|
||||
thisMonth: number
|
||||
average: number | null
|
||||
best: { score: number; tache: Tache; created_at: string } | null
|
||||
}
|
||||
|
||||
export function computeStats(items: SimulationListItem[], now: Date): HistoriqueStats {
|
||||
const total = items.length
|
||||
const thisMonth = items.filter((it) => isInPeriod(it.created_at, 'this-month', now)).length
|
||||
|
||||
const scored = items.filter(
|
||||
(it): it is SimulationListItem & { score: number } => typeof it.score === 'number',
|
||||
)
|
||||
|
||||
if (scored.length === 0) {
|
||||
return { total, thisMonth, average: null, best: null }
|
||||
}
|
||||
|
||||
const sum = scored.reduce((acc, it) => acc + it.score, 0)
|
||||
const average = sum / scored.length
|
||||
|
||||
const bestItem = scored.reduce((acc, it) => (it.score > acc.score ? it : acc), scored[0])
|
||||
|
||||
return {
|
||||
total,
|
||||
thisMonth,
|
||||
average,
|
||||
best: { score: bestItem.score, tache: bestItem.tache, created_at: bestItem.created_at },
|
||||
}
|
||||
}
|
||||
|
||||
export interface Trend {
|
||||
direction: 'up' | 'down'
|
||||
delta: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Tendance 30 j : moyenne des 30 derniers jours vs 30 j précédents (j-60 → j-30).
|
||||
* Ignore les items sans score. Retourne `null` si l'une des deux fenêtres est vide.
|
||||
*/
|
||||
export function computeTrend(items: SimulationListItem[], now: Date): Trend | null {
|
||||
const t = now.getTime()
|
||||
const within = (iso: string, fromDays: number, toDays: number) => {
|
||||
const ts = new Date(iso).getTime()
|
||||
if (!Number.isFinite(ts)) return false
|
||||
const ageMs = t - ts
|
||||
return ageMs >= fromDays * DAY_MS && ageMs < toDays * DAY_MS
|
||||
}
|
||||
|
||||
const recent = items.filter(
|
||||
(it): it is SimulationListItem & { score: number } =>
|
||||
typeof it.score === 'number' && within(it.created_at, 0, 30),
|
||||
)
|
||||
const previous = items.filter(
|
||||
(it): it is SimulationListItem & { score: number } =>
|
||||
typeof it.score === 'number' && within(it.created_at, 30, 60),
|
||||
)
|
||||
|
||||
if (recent.length === 0 || previous.length === 0) return null
|
||||
|
||||
const avg = (xs: { score: number }[]) => xs.reduce((s, it) => s + it.score, 0) / xs.length
|
||||
const delta = avg(recent) - avg(previous)
|
||||
if (delta === 0) return { direction: 'up', delta: 0 }
|
||||
return { direction: delta > 0 ? 'up' : 'down', delta: Math.abs(delta) }
|
||||
}
|
||||
|
||||
const SHORT_DATE = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'short' })
|
||||
|
||||
/** Date courte type "22 avr." (locale fr-FR). */
|
||||
export function formatShortDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (!Number.isFinite(d.getTime())) return ''
|
||||
return SHORT_DATE.format(d)
|
||||
}
|
||||
|
||||
const TACHE_NUMBER: Record<Tache, string> = {
|
||||
EE_T1: 'EE · Tâche 1',
|
||||
EE_T2: 'EE · Tâche 2',
|
||||
EE_T3: 'EE · Tâche 3',
|
||||
EO_T1: 'EO · Tâche 1',
|
||||
EO_T3: 'EO · Tâche 3',
|
||||
}
|
||||
|
||||
/**
|
||||
* Libellé court d'un item d'historique selon la maquette Sprint 4.7.
|
||||
*
|
||||
* Mode entraînement → "EE · Tâche 3"
|
||||
* Mode examen → "Examen blanc EE" / "Examen blanc EO"
|
||||
*/
|
||||
export function formatTaskLabel(item: Pick<SimulationListItem, 'tache' | 'mode'>): string {
|
||||
if (item.mode === 'examen') {
|
||||
return item.tache.startsWith('EE_') ? 'Examen blanc EE' : 'Examen blanc EO'
|
||||
}
|
||||
return TACHE_NUMBER[item.tache]
|
||||
}
|
||||
|
||||
export type NclcChip = 'ok' | 'warn' | 'err'
|
||||
|
||||
/**
|
||||
* Variante visuelle du badge NCLC selon le seuil :
|
||||
* - ≥ 9 → ok (success)
|
||||
* - 7-8 → warn (warning/gold)
|
||||
* - ≤ 6 → err (danger)
|
||||
*/
|
||||
export function nclcChipVariant(nclc: number): NclcChip {
|
||||
if (nclc >= 9) return 'ok'
|
||||
if (nclc >= 7) return 'warn'
|
||||
return 'err'
|
||||
}
|
||||
|
|
@ -1,40 +1,88 @@
|
|||
/**
|
||||
* Page /historique — liste paginée des simulations de l'utilisateur connecté.
|
||||
* Page /historique — refonte Sprint 4.7.
|
||||
*
|
||||
* Consommateurs amont :
|
||||
* - `usePlan` (cache partagé avec dashboard/simulation)
|
||||
* - `useSimulationsList(page, limit)` — cache `['simulations', 'list', p, l]`
|
||||
* Charge en une fois les 50 simulations les plus récentes via
|
||||
* `useSimulationsList(1, 50)` puis applique les filtres (tâche + période)
|
||||
* côté frontend. Cette limite est volontaire : un MVP avec un volume modeste
|
||||
* d'utilisateurs ne nécessite pas de filtrage backend. Au-delà de 50, les
|
||||
* simulations plus anciennes ne sont pas accessibles tant que les filtres
|
||||
* ne sont pas reportés côté backend (`GET /simulations?tache=&since=`).
|
||||
*
|
||||
* Gating Free via `hasAccess(plan, 'dashboard')` délégué à `SimulationsList`.
|
||||
* Pagination : Précédent/Suivant via state local `page`.
|
||||
*
|
||||
* Règle H : aucune logique métier — toute l'orchestration vit dans SimulationsList.
|
||||
* Règle D : gating via `hasAccess(plan, 'dashboard')` dans `SimulationsList`.
|
||||
* Règle L : tokens DA Charcoal exclusivement.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { usePlan } from '@/features/dashboard/hooks/usePlan'
|
||||
import { useSimulationsList } from '../hooks/useSimulationsList'
|
||||
import { SimulationsList } from '../components/SimulationsList'
|
||||
import { HistoriqueStatsCards } from '../components/HistoriqueStats'
|
||||
import { HistoriqueFilters } from '../components/HistoriqueFilters'
|
||||
import {
|
||||
applyFilters,
|
||||
computeStats,
|
||||
computeTrend,
|
||||
type PeriodFilter,
|
||||
type TaskFilter,
|
||||
} from '../lib/historique'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
const LIMIT = 50
|
||||
|
||||
export function HistoriquePage() {
|
||||
const navigate = useNavigate()
|
||||
const [page, setPage] = useState(1)
|
||||
const [task, setTask] = useState<TaskFilter>('all')
|
||||
const [period, setPeriod] = useState<PeriodFilter>('this-month')
|
||||
|
||||
const { data: planData, isLoading: isPlanLoading } = usePlan()
|
||||
const { data, isLoading, isError } = useSimulationsList(page, PAGE_SIZE)
|
||||
const { data, isLoading, isError } = useSimulationsList(1, LIMIT)
|
||||
|
||||
const now = useMemo(() => new Date(), [])
|
||||
const allItems = data?.data ?? []
|
||||
const filtered = useMemo(
|
||||
() => applyFilters(allItems, { task, period }, now),
|
||||
[allItems, task, period, now],
|
||||
)
|
||||
const stats = useMemo(() => computeStats(filtered, now), [filtered, now])
|
||||
const trend = useMemo(() => computeTrend(filtered, now), [filtered, now])
|
||||
|
||||
const isFiltered = task !== 'all' || period !== 'all'
|
||||
const showStats = !isPlanLoading && planData && !isError
|
||||
const canSeeContent = planData && planData.plan !== 'free'
|
||||
|
||||
// AppLayout fournit déjà mx-auto max-w-[1100px] + lg:px-9 lg:py-9 (cf.
|
||||
// AppLayout.tsx) — on limite ici à 860 px sans réintroduire de padding
|
||||
// pour éviter le double margin.
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-lg font-semibold text-ink-primary">Historique</h1>
|
||||
<p className="text-sm text-ink-secondary">
|
||||
Retrouvez toutes vos simulations passées et leur progression.
|
||||
<main className="mx-auto w-full max-w-[860px]">
|
||||
<header className="mb-6 flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.073em] text-ink-tertiary">
|
||||
Historique
|
||||
</p>
|
||||
<h1 className="mt-1 text-[24px] font-bold tracking-[-0.02em] text-ink-primary">
|
||||
Mes simulations
|
||||
</h1>
|
||||
<p className="mt-1 text-[13.5px] text-ink-secondary">
|
||||
Retrouve toutes tes simulations avec leur score et leur rapport.
|
||||
</p>
|
||||
</div>
|
||||
{canSeeContent && (
|
||||
<HistoriqueFilters
|
||||
task={task}
|
||||
period={period}
|
||||
onTaskChange={setTask}
|
||||
onPeriodChange={setPeriod}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showStats && canSeeContent && (
|
||||
<div className="mb-6">
|
||||
<HistoriqueStatsCards stats={stats} trend={trend} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPlanLoading || !planData ? (
|
||||
<div className="space-y-3" aria-busy="true">
|
||||
<div className="h-20 animate-pulse rounded-lg bg-surface" />
|
||||
|
|
@ -43,13 +91,10 @@ export function HistoriquePage() {
|
|||
) : (
|
||||
<SimulationsList
|
||||
plan={planData.plan}
|
||||
data={data}
|
||||
items={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
page={page}
|
||||
limit={PAGE_SIZE}
|
||||
onPrev={() => setPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() => setPage((p) => p + 1)}
|
||||
isFiltered={isFiltered}
|
||||
onUpgrade={() => navigate('/plan')}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export function ProgressionPage() {
|
|||
const isPremium = planData ? hasAccess(planData.plan, 'pattern_analysis') : false
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
<header className="space-y-1">
|
||||
<h1 className="text-lg font-semibold text-ink-primary">Profil de préparation</h1>
|
||||
|
|
@ -56,7 +57,8 @@ export function ProgressionPage() {
|
|||
{isError && (
|
||||
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||
<p className="text-sm text-danger" role="alert">
|
||||
Impossible de charger votre profil de préparation. Réessayez dans quelques instants.
|
||||
Impossible de charger votre profil de préparation. Réessayez dans quelques
|
||||
instants.
|
||||
</p>
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(0)}>
|
||||
|
|
@ -69,5 +71,6 @@ export function ProgressionPage() {
|
|||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,9 +91,12 @@ export function EnregistrementEOPage() {
|
|||
const lockControls = submitting || isCorrecting
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">
|
||||
{formatTache(production.tache)}
|
||||
</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
|
|
@ -167,5 +170,6 @@ export function EnregistrementEOPage() {
|
|||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ export function ModeChoixT1Page() {
|
|||
if (shouldRedirect) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
|
|
@ -43,7 +44,9 @@ export function ModeChoixT1Page() {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Présentation personnelle</h2>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">
|
||||
Tâche 1 — Présentation personnelle
|
||||
</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
|
|
@ -74,5 +77,6 @@ export function ModeChoixT1Page() {
|
|||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export function PreEnregistrementEOPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
|
||||
|
|
@ -69,7 +70,9 @@ export function PreEnregistrementEOPage() {
|
|||
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
|
||||
<p className="font-medium text-ink-primary">Avant de commencer</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li>
|
||||
<li>
|
||||
Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
|
||||
</li>
|
||||
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
|
||||
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
|
||||
{isT1 && (
|
||||
|
|
@ -101,5 +104,6 @@ export function PreEnregistrementEOPage() {
|
|||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,6 +108,7 @@ export function PresentationGenereeT1Page() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
|
|
@ -168,8 +169,8 @@ export function PresentationGenereeT1Page() {
|
|||
role="note"
|
||||
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
|
||||
>
|
||||
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
|
||||
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
|
||||
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois
|
||||
avant d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
|
||||
|
|
@ -194,5 +195,6 @@ export function PresentationGenereeT1Page() {
|
|||
Je suis prêt — Enregistrer
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ export function QuestionnaireT1Page() {
|
|||
if (shouldRedirect) return null
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
|
|
@ -239,5 +240,6 @@ export function QuestionnaireT1Page() {
|
|||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,6 +213,7 @@ export function RapportPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
|
|
@ -285,7 +286,10 @@ export function RapportPage() {
|
|||
/>
|
||||
</BlurredSection>
|
||||
|
||||
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'modele')}
|
||||
onUpgrade={onUpgrade}
|
||||
>
|
||||
<ModeleSection
|
||||
rapport={rapport}
|
||||
hasTimedOut={hasTimedOut}
|
||||
|
|
@ -302,5 +306,6 @@ export function RapportPage() {
|
|||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export function SimulationEOPage() {
|
|||
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationEOSkeleton />}
|
||||
|
||||
|
|
@ -73,5 +74,6 @@ export function SimulationEOPage() {
|
|||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function SimulationPage() {
|
|||
// createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationSkeleton />}
|
||||
|
||||
|
|
@ -96,5 +97,6 @@ export function SimulationPage() {
|
|||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ export function SujetsEOPage() {
|
|||
const hasSujets = (sujets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
|
|
@ -106,7 +107,11 @@ export function SujetsEOPage() {
|
|||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -122,5 +127,6 @@ export function SujetsEOPage() {
|
|||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue