// Générateur de plan d'étude : répartit les concepts sur les jours disponibles avant l'examen, // avec espacement (chaque notion revient ≥ 2 fois) et entrelacement (2-3 thèmes par séance). // Le temps alloué à un concept est proportionnel à (importance × faiblesse). import { masteryForCourse } from "./mastery.ts"; export type PlanConfig = { examDate: string; // ISO weekdays: number[]; // 0=dim … 6=sam — jours disponibles minutesPerSession: number; weeksScope: [number, number]; // semaines de cours couvertes (ex. [1,6] pour l'intra) todayISO?: string; // injectable pour les tests }; export type PlanItem = { kind: "review" | "flashcards" | "quiz" | "exam" | "reading"; conceptSlug: string | null; label: string; minutes: number; done?: boolean }; export type PlanDay = { date: string; items: PlanItem[] }; export function generatePlan(userId: number, courseCode: string, cfg: PlanConfig): PlanDay[] { const today = cfg.todayISO ? new Date(cfg.todayISO) : new Date(); const exam = new Date(cfg.examDate + "T12:00:00"); const days: string[] = []; for (let d = new Date(today); d < exam; d.setDate(d.getDate() + 1)) { if (cfg.weekdays.includes(d.getDay())) days.push(d.toISOString().slice(0, 10)); } if (!days.length) return []; const mastery = masteryForCourse(userId, courseCode).filter( (c) => (c.week ?? 0) >= cfg.weeksScope[0] && (c.week ?? 99) <= cfg.weeksScope[1] ); if (!mastery.length) return []; // Poids : importance × (1 − maîtrise), plancher pour que tout soit revu au moins une fois. const weighted = mastery.map((c) => ({ c, w: Math.max(0.15, c.importance * (1 - c.score)) })); const totalW = weighted.reduce((s, x) => s + x.w, 0); const totalMinutes = days.length * cfg.minutesPerSession; // Réserver ~20 % pour les examens blancs / révisions générales de fin de parcours. const conceptMinutes = totalMinutes * 0.8; // File de blocs de 15-25 minutes par concept, à répartir en round-robin pondéré. type Block = { slug: string; name: string; kind: PlanItem["kind"]; minutes: number }; const blocks: Block[] = []; for (const { c, w } of weighted) { const minutes = Math.max(20, Math.round((conceptMinutes * w) / totalW / 5) * 5); let remaining = minutes; let first = true; while (remaining > 0) { const m = Math.min(25, Math.max(15, remaining)); blocks.push({ slug: c.slug, name: c.name, kind: first ? (c.score < 0.3 ? "review" : "flashcards") : remaining <= 25 ? "quiz" : "flashcards", minutes: m, }); remaining -= m; first = false; } } // Entrelacement : trier par (semaine, passe) puis distribuer en serpentin sur les jours. const plan: PlanDay[] = days.map((date) => ({ date, items: [] })); let dayIdx = 0; const capacity = plan.map(() => cfg.minutesPerSession); for (const b of blocks) { // trouver le prochain jour avec de la place, en évitant 2 blocs consécutifs du même concept let attempts = 0; while (attempts < plan.length) { const i = dayIdx % plan.length; const last = plan[i].items.at(-1); if (capacity[i] >= b.minutes && (!last || last.conceptSlug !== b.slug)) { plan[i].items.push({ kind: b.kind, conceptSlug: b.slug, label: b.kind === "review" ? `Revoir « ${b.name} » (explication + exemple)` : b.kind === "quiz" ? `Quiz ciblé : ${b.name}` : `Cartes mémoire : ${b.name}`, minutes: b.minutes, }); capacity[i] -= b.minutes; dayIdx++; break; } dayIdx++; attempts++; } } // Examens blancs : mi-parcours et avant-dernier jour disponible. const examBlock = (label: string): PlanItem => ({ kind: "exam", conceptSlug: null, label, minutes: Math.min(90, cfg.minutesPerSession) }); if (plan.length >= 4) plan[Math.floor(plan.length / 2)].items.push(examBlock("Examen blanc de mi-parcours (mode pratique)")); if (plan.length >= 2) plan[plan.length - 1].items.push(examBlock("Examen blanc complet (mode chronométré)")); else plan[plan.length - 1].items.push(examBlock("Examen blanc (mode chronométré)")); // Dernier jour : révision du cahier d'erreurs. plan[plan.length - 1].items.push({ kind: "review", conceptSlug: null, label: "Revoir le cahier d'erreurs (toutes les entrées « à revoir »)", minutes: 20 }); return plan.filter((d) => d.items.length); }