SPB Git forge

spb/immbot-ai

Public
1commits 1branches 0releases
1.5 MBsize
maindefault branch
20 days agolast push
TypeScript 98.3% CSS 0.9% Shell 0.7%
12.9 KB · 257 lines tsx
Raw Blame History
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