diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 210cb2b..8ba7752 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -51,6 +51,43 @@ Chaque entrée suit ce format : - B8 : comportement actuel diffère du spec `PARCOURS_UTILISATEURS.md §2 "Quota atteint"` — affichage d'une bannière inline au lieu du modal de blocage attendu. À corriger dans un sprint dédié (non inclus dans ce clean, qui n'introduit aucune nouvelle fonctionnalité). +## [Unreleased] — 2026-04-22 — Sprint 3.7 — Historique (Backend + Frontend) + +### Added (backend) +- `GET /simulations` — liste paginée des productions de l'utilisateur connecté. + - Query params : `page` (défaut 1, entier ≥ 1), `limit` (défaut 20, entier entre 1 et 50). + - Tri : `created_at DESC` côté Supabase. + - Filtre : `user_id = profile.id` (double-protection avec RLS). + - Projection : `id, tache, mode, score, nclc, nclc_cible, created_at` — champs lourds (`contenu`, `rapport`, `exercices`, `modele`) **exclus**. + - Réponse : `{ data: ListItem[], pagination: { page, limit, total } }`. + - Erreurs : `400 VALIDATION_ERROR` si `page`/`limit` invalide, `401 AUTH_REQUIRED` si JWT absent, `500 INTERNAL_ERROR` si DB down. +- `simulationController.list(options, profile)` + interfaces `ListOptions`, `ListItem`, `ListResult`. +- 12 nouveaux tests sur la route `GET /simulations` (186 tests backend verts, +12 vs baseline 174). + +### Added (frontend) +- Page `/historique` (route sous `AppLayout` + `ProtectedRoute`, remplace le placeholder `ComingSoon`). +- `HistoriquePage` — orchestre `usePlan` + `useSimulationsList`, state local de pagination, gating plan Free via `hasAccess('dashboard')`. +- `SimulationsList` — composant liste avec : + - Empty state + CTA « Démarrer une simulation » → `/simulation/ee` + - Loading skeleton (5 barres animées) + - Error state (callout discret `border-l-danger`) + - Aperçu flouté Free + bouton `variant="upgrade"` « Passer en Standard » + - Pagination Précédent / Suivant (masquée si une seule page) + - Affichage « Page X sur Y — Z simulations » +- `SimulationListItem` — carte item : date relative, libellé de tâche (`formatTache`), score `/20`, `NCLC atteint / cible`, badges « Examen » et « En cours » (rapport non prêt). Clic → `/rapport/:id`. +- `useSimulationsList(page, limit)` — hook TanStack Query, clé `['simulations', 'list', page, limit]`, `staleTime: 30s`, `placeholderData: keepPreviousData` pour éviter le flash de squelette au changement de page. +- `listSimulations(page, limit)` dans `entities/production/api.ts` — wrap `apiFetch` + `URLSearchParams`. +- Types `SimulationListItem` et `SimulationsListResponse` dans `entities/production/types.ts`. +- `src/shared/lib/date.ts` — helper `formatRelativeDate(iso, now?)` basé sur `Intl.RelativeTimeFormat('fr', { numeric: 'auto' })`. Seuils : secondes → minutes → heures → jours → semaines → mois → années. Zéro dépendance. +- 18 nouveaux tests frontend (7 `date.test.ts` + 11 `SimulationsList.test.tsx`). + +### Notes +- Les simulations avec `score === null` (en cours ou correction échouée) sont **affichées** avec un badge « En cours ». Clic → `/rapport/:id` — `RapportPage` gère le cas `REPORT_NOT_READY` (FTD-21) en redirigeant vers `/simulation/ee`. +- `BlurredPreview` dupliqué localement dans `SimulationsList` (pattern équivalent à `BlurredSection` de `RapportPage`). À extraire en `shared/` si le pattern se répète dans un 3ᵉ endroit — pas fait dans ce sprint. +- Pagination : Précédent/Suivant (MVP) retenu contre scroll infini. Le choix sera revu si l'historique dépasse 100 items en prod. +- Tests frontend : **102/102 verts** (+18 vs baseline 84). + + ## [Unreleased] — 2026-04-22 — Sprint 3.6b — Qualité correction — Frontend ### Added diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2b80cc0..48e0425 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -55,6 +55,15 @@ - Gating plan conforme PLANS_TARIFAIRES.md : revelation/diagnostic/conseil_nclc tous plans ; criteres/exercices/modele Standard+ - Tests : 84 verts (+8 vs baseline — floutage + helpers lib + ExerciceInteractive) +## Sprint 3.7 — Historique ✅ +- Backend : `GET /simulations` — liste paginée des productions de l'utilisateur connecté (page/limit, tri `created_at DESC`, projection légère). 186 tests backend verts. +- Frontend : page `/historique` (route sous AppLayout), liste d'items (date relative, tâche, score /20, NCLC, badge Examen / En cours), pagination Précédent/Suivant, clic → `/rapport/:id`. +- Gating plan : Free → aperçu flouté + CTA « Passer en Standard » (`hasAccess(plan, 'dashboard')`) ; Standard + Premium → liste complète. +- État vide : CTA « Démarrer une simulation ». +- Hook `useSimulationsList(page, limit)` — TanStack Query, `staleTime: 30s`, `keepPreviousData` pour transitions fluides. +- Helper `formatRelativeDate` (Intl.RelativeTimeFormat, zéro dépendance). +- 102 tests frontend verts (+18 vs baseline 84). + ## Sprint 3.6c — Analyse patterns (Premium) - Backend : GET /users/patterns — agrégation SQL erreurs_codes sur 5 dernières productions - Backend : exercices long terme générés par DeepSeek sur patterns confirmés (≥ 3/5) diff --git a/src/app/router.tsx b/src/app/router.tsx index 06aeda4..e31a88a 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -8,6 +8,7 @@ import { DashboardPage } from '@/features/dashboard/pages/DashboardPage' import { SimulationPage } from '@/features/simulations/pages/SimulationPage' import { SujetsPage } from '@/features/simulations/pages/SujetsPage' import { RapportPage } from '@/features/simulations/pages/RapportPage' +import { HistoriquePage } from '@/features/historique/pages/HistoriquePage' import { SimulationFlowProvider } from '@/features/simulations/state/SimulationFlowProvider' import { AppLayout } from './AppLayout' @@ -71,7 +72,7 @@ export function AppRouter() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/src/entities/production/api.ts b/src/entities/production/api.ts index a325a4b..3f914d9 100644 --- a/src/entities/production/api.ts +++ b/src/entities/production/api.ts @@ -13,6 +13,7 @@ import type { CreateSimulationPayload, Production, SimulationState, + SimulationsListResponse, SujetData, Tache, } from './types' @@ -27,6 +28,19 @@ export function getSimulation(id: string): Promise { return apiFetch(`/simulations/${id}`) } +/** + * Sprint 3.7 — liste paginée des simulations de l'utilisateur connecté. + * Endpoint : `GET /simulations?page=X&limit=Y`. Tri `created_at DESC` côté backend. + * Champs lourds exclus (contenu, rapport, exercices, modele) — cf. SimulationListItem. + */ +export function listSimulations( + page: number, + limit: number, +): Promise { + const qs = new URLSearchParams({ page: String(page), limit: String(limit) }) + return apiFetch(`/simulations?${qs.toString()}`) +} + /** * FTD-21 — récupère l'état complet d'une simulation (contenu + sujet + rapport). * Utilisé par `SimulationFlowProvider` pour restaurer une session depuis diff --git a/src/entities/production/types.ts b/src/entities/production/types.ts index 817b13a..7946edc 100644 --- a/src/entities/production/types.ts +++ b/src/entities/production/types.ts @@ -89,6 +89,30 @@ export interface SimulationState { export type SimulationJobStatus = 'pending' | 'ready' | 'error' +/** + * Sprint 3.7 — Item léger pour la liste /historique. + * Miroir de `ListItem` côté backend (GET /simulations — pagination). + * Les champs lourds (contenu, rapport, exercices, modele) sont **exclus**. + */ +export interface SimulationListItem { + id: string + tache: Tache + mode: Mode + score: number | null + nclc: number | null + nclc_cible: 9 | 10 | null + created_at: string +} + +export interface SimulationsListResponse { + data: SimulationListItem[] + pagination: { + page: number + limit: number + total: number + } +} + /** * Rapport tel que stocké par le backend (sans `simulation_id`, `exercices`, `modele` * qui sont portés par SimulationState). Miroir de `CorrectionRapport` côté backend diff --git a/src/features/historique/components/SimulationListItem.tsx b/src/features/historique/components/SimulationListItem.tsx new file mode 100644 index 0000000..c78165a --- /dev/null +++ b/src/features/historique/components/SimulationListItem.tsx @@ -0,0 +1,59 @@ +/** + * SimulationListItem — Sprint 3.7. + * + * Carte item de la page /historique. Clic → /rapport/:id (RapportPage gère le + * cas `rapport === null` en redirigeant vers /simulation/ee — FTD-21). + * + * Règle L : tokens Direction H exclusivement. + * Règle H : purement présentationnel — aucune logique plan ici. + */ + +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' + +interface Props { + item: Item +} + +export function SimulationListItem({ item }: Props) { + const hasScore = item.score !== null && item.nclc !== null + const isExam = item.mode === 'examen' + + return ( + +
+
+
+ {formatTache(item.tache)} + {isExam && Examen} + {!hasScore && En cours} +
+

{formatRelativeDate(item.created_at)}

+
+ + {hasScore ? ( +
+

+ {item.score} + /20 +

+

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

+
+ ) : ( +
+ Score à venir +
+ )} +
+ + ) +} diff --git a/src/features/historique/components/SimulationsList.tsx b/src/features/historique/components/SimulationsList.tsx new file mode 100644 index 0000000..12258ce --- /dev/null +++ b/src/features/historique/components/SimulationsList.tsx @@ -0,0 +1,147 @@ +/** + * SimulationsList — Sprint 3.7. + * + * Orchestre : + * - Loading / error / empty state + * - Gating plan : Free → aperçu flouté + upgrade ; Standard+ → liste + * - Pagination Précédent/Suivant (MVP) + * + * Règle D : passe par `hasAccess(plan, 'dashboard')` — jamais de `plan === 'free'`. + * Règle L : tokens Direction H exclusivement. + */ + +import { Lock } from 'lucide-react' +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 { SimulationListItem } from './SimulationListItem' + +interface Props { + plan: Plan + data: SimulationsListResponse | undefined + isLoading: boolean + isError: boolean + page: number + limit: number + onPrev: () => void + onNext: () => void + 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 ( +
+ + ) +} + +function ListSkeleton() { + return ( +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ ) +} + +function EmptyState() { + return ( + +

Aucune simulation pour le moment.

+

+ Lancez votre première simulation pour commencer à construire votre historique. +

+
+ +
+
+ ) +} + +function ErrorState() { + return ( + +

+ Impossible de charger l'historique. Réessayez dans quelques instants. +

+
+ ) +} + +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). + if (!hasAccess(plan, 'dashboard')) { + return + } + + if (isError) return + if (isLoading && !data) return + if (!data) return null + + if (data.data.length === 0) return + + const totalPages = Math.max(1, Math.ceil(data.pagination.total / limit)) + const isFirst = page <= 1 + const isLast = page >= totalPages + + return ( +
+
    + {data.data.map((item) => ( +
  • + +
  • + ))} +
+ + {totalPages > 1 && ( + + )} +
+ ) +} diff --git a/src/features/historique/components/__tests__/SimulationsList.test.tsx b/src/features/historique/components/__tests__/SimulationsList.test.tsx new file mode 100644 index 0000000..cef8d1c --- /dev/null +++ b/src/features/historique/components/__tests__/SimulationsList.test.tsx @@ -0,0 +1,310 @@ +/** + * Tests — SimulationsList (Sprint 3.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é + */ + +import { describe, it, expect, vi, afterEach } from 'vitest' +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' + +afterEach(cleanup) + +function renderWithRouter(ui: React.ReactNode) { + return render({ui}) +} + +const EMPTY: SimulationsListResponse = { + data: [], + pagination: { page: 1, limit: 20, total: 0 }, +} + +const THREE_ITEMS: SimulationsListResponse = { + data: [ + { + id: 'p1', + tache: 'EE_T1', + mode: 'entrainement', + score: 14, + nclc: 9, + nclc_cible: 9, + created_at: '2026-04-22T10:00:00Z', + }, + { + id: 'p2', + tache: 'EE_T2', + mode: 'examen', + score: 16, + nclc: 10, + nclc_cible: 10, + created_at: '2026-04-22T09:00:00Z', + }, + { + id: 'p3', + tache: 'EE_T3', + mode: 'entrainement', + score: null, + nclc: null, + 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"', () => { + renderWithRouter( + , + ) + + 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() + }) + + it('clic sur "Passer en Standard" appelle onUpgrade', async () => { + const user = userEvent.setup() + const onUpgrade = vi.fn() + renderWithRouter( + , + ) + + 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"', () => { + renderWithRouter( + , + ) + + expect(screen.getByText(/aucune simulation/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', () => { + renderWithRouter( + , + ) + + // 3 items rendus + 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() + }) + + it('chaque item pointe vers /rapport/:id', () => { + renderWithRouter( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + expect(screen.getByText(/Page 2 sur 3 — 50 simulations/i)).toBeInTheDocument() + }) +}) + +describe('SimulationsList — états transverses', () => { + it('isError → affiche le callout d\'erreur', () => { + renderWithRouter( + , + ) + + expect(screen.getByRole('alert')).toHaveTextContent(/impossible de charger/i) + }) + + it('isLoading + pas de data → squelettes', () => { + renderWithRouter( + , + ) + + expect(screen.getByLabelText(/chargement de l'historique/i)).toBeInTheDocument() + }) +}) diff --git a/src/features/historique/hooks/useSimulationsList.ts b/src/features/historique/hooks/useSimulationsList.ts new file mode 100644 index 0000000..dab8aad --- /dev/null +++ b/src/features/historique/hooks/useSimulationsList.ts @@ -0,0 +1,27 @@ +/** + * Hook TanStack Query — liste paginée des simulations. + * + * Clé de cache : `['simulations', 'list', page, limit]`. `staleTime: 30 s` — + * l'historique change peu entre deux requêtes utilisateur, 30 s évite les + * rafraîchissements inutiles tout en gardant les données fraîches. + * + * `placeholderData: keepPreviousData` (TanStack v5) permet un changement de + * page sans flash de squelette — les items précédents restent affichés + * pendant le fetch. + * + * Règle H : aucune logique métier ici — le hook ne fait qu'envelopper l'API. + */ + +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { listSimulations } from '@/entities/production/api' + +const DEFAULT_LIMIT = 20 + +export function useSimulationsList(page: number, limit: number = DEFAULT_LIMIT) { + return useQuery({ + queryKey: ['simulations', 'list', page, limit] as const, + queryFn: () => listSimulations(page, limit), + staleTime: 30 * 1000, + placeholderData: keepPreviousData, + }) +} diff --git a/src/features/historique/pages/HistoriquePage.tsx b/src/features/historique/pages/HistoriquePage.tsx new file mode 100644 index 0000000..09d8c64 --- /dev/null +++ b/src/features/historique/pages/HistoriquePage.tsx @@ -0,0 +1,58 @@ +/** + * Page /historique — liste paginée des simulations de l'utilisateur connecté. + * + * Consommateurs amont : + * - `usePlan` (cache partagé avec dashboard/simulation) + * - `useSimulationsList(page, limit)` — cache `['simulations', 'list', p, l]` + * + * 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. + */ + +import { 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' + +const PAGE_SIZE = 20 + +export function HistoriquePage() { + const navigate = useNavigate() + const [page, setPage] = useState(1) + + const { data: planData, isLoading: isPlanLoading } = usePlan() + const { data, isLoading, isError } = useSimulationsList(page, PAGE_SIZE) + + return ( +
+
+

Historique

+

+ Retrouvez toutes vos simulations passées et leur progression. +

+
+ + {isPlanLoading || !planData ? ( +
+
+
+
+ ) : ( + setPage((p) => Math.max(1, p - 1))} + onNext={() => setPage((p) => p + 1)} + onUpgrade={() => navigate('/plan')} + /> + )} +
+ ) +} diff --git a/src/shared/lib/__tests__/date.test.ts b/src/shared/lib/__tests__/date.test.ts new file mode 100644 index 0000000..af3429d --- /dev/null +++ b/src/shared/lib/__tests__/date.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest' +import { formatRelativeDate } from '../date' + +const NOW = new Date('2026-04-22T12:00:00Z') + +describe('formatRelativeDate', () => { + it('il y a quelques secondes', () => { + const d = new Date(NOW.getTime() - 10 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toMatch(/seconde/i) + }) + + it('il y a 5 minutes', () => { + const d = new Date(NOW.getTime() - 5 * 60 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toContain('5') + expect(formatRelativeDate(d, NOW)).toMatch(/minute/i) + }) + + it('il y a 3 heures', () => { + const d = new Date(NOW.getTime() - 3 * 60 * 60 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toContain('3') + expect(formatRelativeDate(d, NOW)).toMatch(/heure/i) + }) + + it('avant-hier (numeric: auto)', () => { + const d = new Date(NOW.getTime() - 2 * 24 * 60 * 60 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toBe('avant-hier') + }) + + it('il y a 4 jours', () => { + const d = new Date(NOW.getTime() - 4 * 24 * 60 * 60 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toContain('4') + expect(formatRelativeDate(d, NOW)).toMatch(/jour/i) + }) + + it('la semaine dernière (numeric: auto)', () => { + const d = new Date(NOW.getTime() - 8 * 24 * 60 * 60 * 1000).toISOString() + expect(formatRelativeDate(d, NOW)).toMatch(/semaine/i) + }) + + it('retourne une chaîne vide sur ISO invalide', () => { + expect(formatRelativeDate('not-a-date', NOW)).toBe('') + }) +}) diff --git a/src/shared/lib/date.ts b/src/shared/lib/date.ts new file mode 100644 index 0000000..f6d06ba --- /dev/null +++ b/src/shared/lib/date.ts @@ -0,0 +1,32 @@ +/** + * Helper de formatage de dates relatives — zéro dépendance (Intl.RelativeTimeFormat). + * + * Exemple : `formatRelativeDate('2026-04-22T10:00:00Z', now)` → « il y a 2 jours » + * + * Seuils : secondes → minutes → heures → jours → semaines → mois → années. + * Locale fixée à `'fr'` — Expria est monolingue français (cf. DESIGN_SYSTEM.md §10). + */ + +const RTF = new Intl.RelativeTimeFormat('fr', { numeric: 'auto' }) + +const MINUTE = 60 +const HOUR = 60 * MINUTE +const DAY = 24 * HOUR +const WEEK = 7 * DAY +const MONTH = 30 * DAY +const YEAR = 365 * DAY + +export function formatRelativeDate(iso: string, now: Date = new Date()): string { + const then = new Date(iso).getTime() + if (!Number.isFinite(then)) return '' + const diffSec = Math.round((then - now.getTime()) / 1000) + const abs = Math.abs(diffSec) + + if (abs < MINUTE) return RTF.format(Math.round(diffSec / 1), 'second') + if (abs < HOUR) return RTF.format(Math.round(diffSec / MINUTE), 'minute') + if (abs < DAY) return RTF.format(Math.round(diffSec / HOUR), 'hour') + if (abs < WEEK) return RTF.format(Math.round(diffSec / DAY), 'day') + if (abs < MONTH) return RTF.format(Math.round(diffSec / WEEK), 'week') + if (abs < YEAR) return RTF.format(Math.round(diffSec / MONTH), 'month') + return RTF.format(Math.round(diffSec / YEAR), 'year') +}