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.7 KB · 293 lines tsx
Raw Blame History
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