TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1"use client";2// Plan d'étude : formulaire de création (date d'examen, jours disponibles,3// minutes par séance, portée des semaines) puis calendrier vertical cochable.4import { useCallback, useEffect, useMemo, useState } from "react";5import { CalendarDays, Check, RefreshCw } from "lucide-react";6import { Badge, Button, Card, Label, ProgressBar, Skeleton, Spinner, cn } from "@/components/ui";7import { ErrorBanner, fetchJson, fmtDate, postJson } from "./shared";89type PlanItem = { kind: "review" | "flashcards" | "quiz" | "exam" | "reading"; conceptSlug: string | null; label: string; minutes: number; done?: boolean };10type PlanDay = { date: string; items: PlanItem[] };11type Plan = {12 id: number;13 examDate: string;14 config: { weekdays: number[]; minutesPerSession: number; weeksScope: [number, number] };15 days: PlanDay[];16 createdAt: string;17};1819const WEEKDAYS: { value: number; label: string; full: string }[] = [20 { value: 1, label: "L", full: "Lundi" },21 { value: 2, label: "M", full: "Mardi" },22 { value: 3, label: "M", full: "Mercredi" },23 { value: 4, label: "J", full: "Jeudi" },24 { value: 5, label: "V", full: "Vendredi" },25 { value: 6, label: "S", full: "Samedi" },26 { value: 0, label: "D", full: "Dimanche" },27];2829const KIND_BADGES: Record<PlanItem["kind"], { label: string; tone: "brand" | "gold" | "green" | "amber" | "neutral" }> = {30 review: { label: "Réviser", tone: "brand" },31 flashcards: { label: "Cartes", tone: "green" },32 quiz: { label: "Quiz", tone: "amber" },33 exam: { label: "Examen", tone: "gold" },34 reading: { label: "Lecture", tone: "neutral" },35};3637function dayLabel(iso: string): string {38 return new Date(iso + "T12:00:00").toLocaleDateString("fr-CA", { weekday: "long", day: "numeric", month: "long" });39}4041export function PlanApp({ course }: { course: string }) {42 const [plan, setPlan] = useState<Plan | null | undefined>(undefined); // undefined = chargement43 const [error, setError] = useState<string | null>(null);44 const [showForm, setShowForm] = useState(false);45 const [busy, setBusy] = useState(false);4647 // Formulaire48 const [examDate, setExamDate] = useState("");49 const [weekdays, setWeekdays] = useState<number[]>([1, 3, 6]);50 const [minutes, setMinutes] = useState(60);51 const [weekFrom, setWeekFrom] = useState(1);52 const [weekTo, setWeekTo] = useState(14);5354 const load = useCallback(() => {55 setError(null);56 fetchJson<{ plan: Plan | null }>(`/api/learning/${course}/plan`)57 .then((d) => setPlan(d.plan))58 .catch((e) => { setPlan(null); setError(e instanceof Error ? e.message : "Erreur de chargement."); });59 }, [course]);6061 useEffect(() => { load(); }, [load]);6263 async function create(e: React.FormEvent) {64 e.preventDefault();65 if (!examDate || weekdays.length === 0) return;66 setBusy(true);67 setError(null);68 try {69 await postJson(`/api/learning/${course}/plan`, {70 action: "create",71 examDate,72 weekdays,73 minutesPerSession: minutes,74 weeksScope: [Math.min(weekFrom, weekTo), Math.max(weekFrom, weekTo)],75 });76 setShowForm(false);77 load();78 } catch (err) {79 setError(err instanceof Error ? err.message : "Erreur de création du plan.");80 } finally {81 setBusy(false);82 }83 }8485 async function toggle(date: string, itemIndex: number, done: boolean) {86 if (!plan) return;87 // Optimiste88 setPlan((p) => {89 if (!p) return p;90 const days = p.days.map((d) =>91 d.date === date ? { ...d, items: d.items.map((it, i) => (i === itemIndex ? { ...it, done } : it)) } : d92 );93 return { ...p, days };94 });95 try {96 await postJson(`/api/learning/${course}/plan`, { action: "toggle", planId: plan.id, date, itemIndex, done });97 } catch (err) {98 setError(err instanceof Error ? err.message : "Erreur d'enregistrement.");99 load();100 }101 }102103 const todayISO = new Date().toLocaleDateString("fr-CA"); // YYYY-MM-DD local104 const progress = useMemo(() => {105 if (!plan) return { done: 0, total: 0 };106 let done = 0, total = 0;107 for (const d of plan.days) for (const it of d.items) { total++; if (it.done) done++; }108 return { done, total };109 }, [plan]);110111 if (plan === undefined) {112 return (113 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full space-y-3">114 <Skeleton className="h-8 w-56" />115 <Skeleton className="h-40 w-full" />116 <Skeleton className="h-40 w-full" />117 </main>118 );119 }120121 // ---------- Formulaire ----------122 if (!plan || showForm) {123 return (124 <main className="px-4 sm:px-6 py-6 max-w-2xl mx-auto w-full">125 <div className="mb-5">126 <h2 className="text-lg font-bold text-fg">Plan d'étude</h2>127 <p className="text-[13px] text-muted">128 Un calendrier réaliste jusqu'à votre examen : espacement (chaque notion revient plus d'une fois)129 et entrelacement (plusieurs thèmes par séance), pondéré par vos faiblesses.130 </p>131 </div>132 <Card className="p-6 animate-fade-up">133 <form onSubmit={create} className="space-y-5">134 <div>135 <Label htmlFor="plan-date">Date de l'examen</Label>136 <input137 id="plan-date"138 type="date"139 required140 value={examDate}141 min={new Date(Date.now() + 86400_000).toLocaleDateString("fr-CA")}142 onChange={(e) => setExamDate(e.target.value)}143 className="w-full h-10 px-3.5 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400 focus:ring-2 focus:ring-brand-500/25"144 />145 </div>146 <div>147 <Label>Jours disponibles pour étudier</Label>148 <div className="flex gap-1.5 flex-wrap" role="group" aria-label="Jours de la semaine">149 {WEEKDAYS.map((d) => {150 const on = weekdays.includes(d.value);151 return (152 <button153 key={d.value}154 type="button"155 title={d.full}156 aria-pressed={on}157 onClick={() => setWeekdays((w) => (on ? w.filter((x) => x !== d.value) : [...w, d.value]))}158 className={cn(159 "w-10 h-10 rounded-full text-sm font-semibold border transition-colors",160 on ? "bg-brand-600 border-brand-600 text-white" : "bg-card border-app text-muted hover:text-fg"161 )}162 >163 {d.label}164 </button>165 );166 })}167 </div>168 {weekdays.length === 0 && <p className="text-[12px] text-red-500 mt-1.5">Choisissez au moins un jour.</p>}169 </div>170 <div>171 <Label htmlFor="plan-minutes">Minutes par séance : {minutes} min</Label>172 <input173 id="plan-minutes" type="range" min={20} max={180} step={10}174 value={minutes} onChange={(e) => setMinutes(Number(e.target.value))}175 className="w-full accent-[#1d64b0]"176 />177 <div className="flex justify-between text-[11px] text-muted"><span>20 min</span><span>180 min</span></div>178 </div>179 <div className="grid grid-cols-2 gap-3">180 <div>181 <Label htmlFor="plan-wfrom">De la semaine</Label>182 <select183 id="plan-wfrom" value={weekFrom} onChange={(e) => setWeekFrom(Number(e.target.value))}184 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"185 >186 {Array.from({ length: 14 }, (_, i) => i + 1).map((w) => <option key={w} value={w}>Semaine {w}</option>)}187 </select>188 </div>189 <div>190 <Label htmlFor="plan-wto">À la semaine</Label>191 <select192 id="plan-wto" value={weekTo} onChange={(e) => setWeekTo(Number(e.target.value))}193 className="w-full h-10 px-3 rounded-lg bg-card border border-app text-sm text-fg outline-none focus:border-brand-400"194 >195 {Array.from({ length: 14 }, (_, i) => i + 1).map((w) => <option key={w} value={w}>Semaine {w}</option>)}196 </select>197 </div>198 </div>199 {error && <ErrorBanner message={error} />}200 <div className="flex gap-2">201 <Button type="submit" disabled={busy || weekdays.length === 0} className="flex-1 justify-center">202 {busy ? <Spinner /> : "Générer mon plan"}203 </Button>204 {plan && (205 <Button type="button" variant="secondary" onClick={() => setShowForm(false)}>Annuler</Button>206 )}207 </div>208 </form>209 </Card>210 </main>211 );212 }213214 // ---------- Calendrier ----------215 return (216 <main className="px-4 sm:px-6 py-6 max-w-3xl mx-auto w-full">217 <div className="flex flex-wrap items-center justify-between gap-3 mb-4">218 <div>219 <h2 className="text-lg font-bold text-fg">Plan d'étude</h2>220 <p className="text-[13px] text-muted">221 Examen le <span className="font-medium text-fg">{fmtDate(plan.examDate + "T12:00:00")}</span> ·{" "}222 {plan.config.minutesPerSession} min/séance · semaines {plan.config.weeksScope[0]}–{plan.config.weeksScope[1]}223 </p>224 </div>225 <Button variant="secondary" size="sm" onClick={() => { setError(null); setShowForm(true); }}>226 <RefreshCw size={13} /> Régénérer un plan227 </Button>228 </div>229230 <Card className="p-4 mb-5">231 <div className="flex justify-between text-[13px] mb-1.5">232 <span className="font-medium text-fg">Progression du plan</span>233 <span className="text-muted tabular-nums">{progress.done}/{progress.total} activités</span>234 </div>235 <ProgressBar value={progress.total ? progress.done / progress.total : 0} tone={progress.done === progress.total && progress.total > 0 ? "green" : "brand"} />236 </Card>237238 {error && <ErrorBanner message={error} className="mb-4" />}239240 <ol className="space-y-3" aria-label="Calendrier du plan d'étude">241 {plan.days.map((day) => {242 const isToday = day.date === todayISO;243 const isPast = day.date < todayISO;244 const allDone = day.items.every((it) => it.done);245 return (246 <li key={day.date}>247 <Card className={cn("p-4 animate-fade-up", isToday && "border-brand-500 ring-2 ring-brand-500/20", isPast && !allDone && "opacity-90")}>248 <div className="flex items-center gap-2 mb-2.5">249 <CalendarDays size={15} className={isToday ? "text-brand-500" : "text-muted"} />250 <h3 className={cn("text-sm font-semibold capitalize", isToday ? "text-brand-600 dark:text-brand-300" : "text-fg")}>251 {dayLabel(day.date)}252 </h3>253 {isToday && <Badge tone="brand">Aujourd'hui</Badge>}254 {allDone && <Badge tone="green"><Check size={11} /> Complété</Badge>}255 <span className="ml-auto text-[11.5px] text-muted tabular-nums">256 {day.items.reduce((s, it) => s + it.minutes, 0)} min257 </span>258 </div>259 <ul className="space-y-1.5">260 {day.items.map((it, i) => {261 const kb = KIND_BADGES[it.kind] ?? KIND_BADGES.review;262 return (263 <li key={i}>264 <label className={cn(265 "flex items-start gap-2.5 rounded-lg px-2 py-1.5 -mx-2 cursor-pointer transition-colors",266 "hover:bg-surface-2 dark:hover:bg-brand-900/30"267 )}>268 <input269 type="checkbox"270 checked={!!it.done}271 onChange={(e) => toggle(day.date, i, e.target.checked)}272 className="mt-0.5 w-4 h-4 rounded accent-[#1d64b0] shrink-0"273 aria-label={it.label}274 />275 <span className={cn("text-[13.5px] flex-1 min-w-0", it.done ? "text-muted line-through" : "text-fg")}>276 {it.label}277 </span>278 <Badge tone={kb.tone} className="shrink-0">{kb.label}</Badge>279 <span className="text-[11.5px] text-muted tabular-nums shrink-0 mt-0.5">{it.minutes} min</span>280 </label>281 </li>282 );283 })}284 </ul>285 </Card>286 </li>287 );288 })}289 </ol>290 </main>291 );292}293