TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Tableau de progression : score global, série, statistiques, maîtrise par semaine,3// concepts groupés par niveau, recommandations justifiées, activité récente.4import Link from "next/link";5import { useEffect, useState } from "react";6import {7 Activity, AlertTriangle, ArrowRight, BellRing, CheckCircle2, Clock3, Flame,8 Layers, ListChecks, Target,9} from "lucide-react";10import { Badge, Card, EmptyState, Skeleton, cn } from "@/components/ui";11import { ErrorBanner, LEVELS, ProgressRing, fetchJson, fmtDateTime, levelInfo, type MasteryLevel } from "./shared";1213type Concept = {14 conceptId: number; slug: string; name: string; week: number | null;15 importance: number; axis: string; score: number; level: MasteryLevel; observations: number;16};17type Recommendation = { kind: string; label: string; reason: string; href: string; courseCode: string; priority: number };18type Overview = {19 globalScore: number;20 levels: Record<string, string>;21 concepts: Concept[];22 practicedCount: number;23 weekScores: { week: number; score: number }[];24 streak: number;25 stats: {26 cardsReviewed: number; quizAnswered: number; quizAccuracy: number | null;27 examAttempts: number; studyMinutes: number; errorsToReview: number; cardsDue: number;28 };29 recent: { kind: string; meta: string | null; created_at: string }[];30 recommendations: Recommendation[];31};3233const ACTIVITY_LABELS: Record<string, string> = {34 chat: "Conversation avec le tuteur",35 flashcards: "Révision de cartes mémoire",36 quiz: "Quiz adaptatif",37 exam: "Examen blanc",38 summary: "Génération d'un résumé",39 plan: "Plan d'étude",40};4142const LEVEL_ORDER: MasteryLevel[] = ["maitrise", "solide", "en-construction", "a-decouvrir"];4344export function ProgressDashboard({ course }: { course: string }) {45 const [data, setData] = useState<Overview | null>(null);46 const [error, setError] = useState<string | null>(null);4748 useEffect(() => {49 fetchJson<Overview>(`/api/learning/${course}/overview`)50 .then(setData)51 .catch((e) => setError(e instanceof Error ? e.message : "Erreur de chargement."));52 }, [course]);5354 if (error) {55 return <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full"><ErrorBanner message={error} /></main>;56 }57 if (!data) {58 return (59 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full space-y-4">60 <div className="grid sm:grid-cols-3 gap-4">61 <Skeleton className="h-44" /><Skeleton className="h-44" /><Skeleton className="h-44" />62 </div>63 <Skeleton className="h-40 w-full" />64 <Skeleton className="h-64 w-full" />65 </main>66 );67 }6869 const stats = [70 { icon: Layers, label: "Cartes révisées", value: String(data.stats.cardsReviewed) },71 { icon: ListChecks, label: "Questions de quiz", value: String(data.stats.quizAnswered) },72 { icon: Target, label: "Précision aux quiz", value: data.stats.quizAccuracy == null ? "—" : `${data.stats.quizAccuracy} %` },73 { icon: CheckCircle2, label: "Examens blancs", value: String(data.stats.examAttempts) },74 { icon: Clock3, label: "Minutes d'étude", value: String(data.stats.studyMinutes) },75 { icon: AlertTriangle, label: "Erreurs à revoir", value: String(data.stats.errorsToReview) },76 ];7778 return (79 <main className="px-4 sm:px-6 py-6 max-w-5xl mx-auto w-full space-y-7">80 {/* Rangée d'entête compacte : score global + série + cartes dues */}81 <div className="grid grid-cols-3 gap-2.5 sm:gap-4">82 <Card className="p-3 sm:p-5 flex flex-col items-center justify-center animate-fade-up">83 <ProgressRing value={data.globalScore} />84 <p className="hidden sm:block text-[12px] text-muted mt-2 text-center">85 Estimation pondérée ({data.practicedCount}/{data.concepts.length} notions pratiquées)86 </p>87 <p className="sm:hidden text-[11px] text-muted mt-1.5 text-center leading-tight">maîtrise estimée</p>88 </Card>89 <Card className="p-3 sm:p-5 flex flex-col items-center justify-center animate-fade-up">90 <div className={cn("flex items-center gap-1.5", data.streak > 0 ? "text-amber-500" : "text-muted")}>91 <Flame size={26} className={data.streak > 0 ? "fill-amber-500/25" : ""} />92 <span className="text-3xl sm:text-4xl font-bold text-fg tracking-tight">{data.streak}</span>93 </div>94 <p className="text-[11px] sm:text-sm font-medium text-fg mt-1 sm:mt-1.5 text-center leading-tight">95 jour{data.streak > 1 ? "s" : ""} de suite96 </p>97 <p className="hidden sm:block text-[12px] text-muted mt-0.5 text-center">98 {data.streak > 0 ? "La régularité bat l'intensité — continuez !" : "Une activité aujourd'hui démarre votre série."}99 </p>100 </Card>101 <Link href={`/apprendre/${course}/flashcards`} className="block group">102 <Card className="h-full p-3 sm:p-5 flex flex-col items-center justify-center animate-fade-up group-hover:border-brand-400 transition-colors">103 <span className={cn("text-3xl sm:text-4xl font-bold tracking-tight", data.stats.cardsDue > 0 ? "text-brand-600 dark:text-brand-300" : "text-fg")}>104 {data.stats.cardsDue}105 </span>106 <p className="text-[11px] sm:text-sm font-medium text-fg mt-1 sm:mt-1.5 text-center leading-tight">107 carte{data.stats.cardsDue > 1 ? "s" : ""} due{data.stats.cardsDue > 1 ? "s" : ""}108 </p>109 <span className="mt-1 sm:mt-2 text-[11px] sm:text-[13px] font-semibold text-brand-600 dark:text-brand-300 inline-flex items-center gap-1">110 Réviser <ArrowRight size={12} />111 </span>112 </Card>113 </Link>114 </div>115116 {/* Statistiques */}117 <section aria-label="Statistiques">118 <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">119 {stats.map((s) => (120 <Card key={s.label} className="p-3.5 animate-fade-up">121 <s.icon size={15} className="text-muted mb-1.5" />122 <p className="text-xl font-bold text-fg leading-none">{s.value}</p>123 <p className="text-[11.5px] text-muted mt-1">{s.label}</p>124 </Card>125 ))}126 </div>127 </section>128129 {/* Maîtrise par semaine */}130 <section aria-label="Maîtrise par semaine">131 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Maîtrise par semaine</h2>132 <Card className="p-5">133 {data.weekScores.length === 0 ? (134 <p className="text-sm text-muted">Aucune donnée encore — les barres apparaîtront après vos premières activités.</p>135 ) : (136 <div className="flex items-end gap-1.5 sm:gap-2.5 h-36" role="img" aria-label="Barres de maîtrise par semaine de cours">137 {data.weekScores.map((w) => {138 const pct = Math.round(w.score * 100);139 const lv = levelInfo(w.score >= 0.85 ? "maitrise" : w.score >= 0.6 ? "solide" : w.score >= 0.3 ? "en-construction" : "a-decouvrir");140 return (141 <div key={w.week} className="flex-1 flex flex-col items-center gap-1.5 min-w-0 h-full justify-end" title={`Semaine ${w.week} : ${pct} %`}>142 <span className="text-[10.5px] text-muted tabular-nums">{pct}</span>143 <div className="w-full max-w-8 bg-surface-2 dark:bg-brand-900/50 rounded-t-md flex flex-col justify-end" style={{ height: "78%" }}>144 <div className={cn("w-full rounded-t-md transition-all duration-700", lv.bg)} style={{ height: `${Math.max(3, pct)}%` }} />145 </div>146 <span className="text-[10.5px] text-muted">S{w.week}</span>147 </div>148 );149 })}150 </div>151 )}152 </Card>153 </section>154155 <div className="grid lg:grid-cols-5 gap-6 items-start">156 {/* Concepts par niveau */}157 <section aria-label="Concepts par niveau" className="lg:col-span-3">158 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Concepts par niveau de maîtrise</h2>159 <div className="space-y-4">160 {LEVEL_ORDER.map((lvl) => {161 const items = data.concepts.filter((c) => c.level === lvl);162 if (!items.length) return null;163 const info = LEVELS[lvl];164 return (165 <Card key={lvl} className="p-4">166 <div className="flex items-center gap-2 mb-2.5">167 <span className={cn("w-2.5 h-2.5 rounded-full", info.bg)} aria-hidden />168 <h3 className="text-sm font-semibold text-fg">{info.label}</h3>169 <span className="text-[12px] text-muted">({items.length})</span>170 </div>171 <div className="flex flex-wrap gap-1.5">172 {items.map((c) => (173 <Link174 key={c.conceptId}175 href={`/apprendre/${course}/concepts?focus=${encodeURIComponent(c.slug)}`}176 title={`${c.name} — semaine ${c.week ?? "?"} · maîtrise estimée ${Math.round(c.score * 100)} %`}177 className={cn(178 "inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg border border-app bg-card text-[12.5px] text-fg",179 "hover:border-brand-400 hover:bg-brand-50 dark:hover:bg-brand-900/30 transition-colors"180 )}181 >182 <span className={cn("font-semibold tabular-nums", info.text)}>{Math.round(c.score * 100)}</span>183 <span className="truncate max-w-52">{c.name}</span>184 {c.week != null && <span className="text-[10.5px] text-muted">S{c.week}</span>}185 </Link>186 ))}187 </div>188 </Card>189 );190 })}191 {data.concepts.length === 0 && (192 <EmptyState icon={<Target />} title="Aucun concept" description="La carte des concepts de ce cours n'est pas encore chargée." />193 )}194 </div>195 </section>196197 {/* Recommandations + activité récente */}198 <div className="lg:col-span-2 space-y-6">199 <section aria-label="Recommandations">200 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Recommandé maintenant</h2>201 {data.recommendations.length === 0 ? (202 <Card className="p-4"><p className="text-sm text-muted">Rien d'urgent — poursuivez votre plan ou explorez la carte des concepts.</p></Card>203 ) : (204 <div className="space-y-2.5">205 {data.recommendations.map((r, i) => (206 <Link key={`${r.kind}-${i}`} href={r.href} className="block group">207 <Card className="p-3.5 hover:border-brand-300 transition-colors">208 <div className="flex items-center gap-2">209 <BellRing size={14} className="text-brand-500 shrink-0" />210 <p className="text-[13.5px] font-semibold text-fg group-hover:text-brand-600 dark:group-hover:text-brand-300 transition-colors">211 {r.label}212 </p>213 </div>214 <p className="text-[12px] text-muted mt-1">215 <span className="font-medium">Pourquoi :</span> {r.reason}216 </p>217 </Card>218 </Link>219 ))}220 </div>221 )}222 </section>223224 <section aria-label="Activité récente">225 <h2 className="text-[13px] font-semibold text-muted uppercase tracking-wide mb-3">Activité récente</h2>226 <Card className="p-4">227 {data.recent.length === 0 ? (228 <p className="text-sm text-muted">Aucune activité enregistrée pour ce cours.</p>229 ) : (230 <ol className="space-y-2.5">231 {data.recent.map((a, i) => (232 <li key={i} className="flex items-center gap-2.5 text-[13px]">233 <Activity size={13} className="text-muted shrink-0" />234 <span className="text-fg flex-1 min-w-0 truncate">{ACTIVITY_LABELS[a.kind] ?? a.kind}</span>235 <span className="text-[11.5px] text-muted shrink-0">{fmtDateTime(a.created_at)}</span>236 </li>237 ))}238 </ol>239 )}240 </Card>241 </section>242 </div>243 </div>244245 {/* Légende des niveaux */}246 <div className="flex flex-wrap gap-3 text-[12px] text-muted">247 {LEVEL_ORDER.map((lvl) => (248 <span key={lvl} className="inline-flex items-center gap-1.5">249 <span className={cn("w-2 h-2 rounded-full", LEVELS[lvl].bg)} /> {LEVELS[lvl].label}250 </span>251 ))}252 <span className="ml-auto">La maîtrise est une estimation : elle décroît doucement sans pratique.</span>253 </div>254 </main>255 );256}257