TypeScript 98.3%
CSS 0.9%
Shell 0.7%
1// Générateur de plan d'étude : répartit les concepts sur les jours disponibles avant l'examen,2// avec espacement (chaque notion revient ≥ 2 fois) et entrelacement (2-3 thèmes par séance).3// Le temps alloué à un concept est proportionnel à (importance × faiblesse).45import { masteryForCourse } from "./mastery.ts";67export type PlanConfig = {8 examDate: string; // ISO9 weekdays: number[]; // 0=dim … 6=sam — jours disponibles10 minutesPerSession: number;11 weeksScope: [number, number]; // semaines de cours couvertes (ex. [1,6] pour l'intra)12 todayISO?: string; // injectable pour les tests13};1415export type PlanItem = { kind: "review" | "flashcards" | "quiz" | "exam" | "reading"; conceptSlug: string | null; label: string; minutes: number; done?: boolean };16export type PlanDay = { date: string; items: PlanItem[] };1718export function generatePlan(userId: number, courseCode: string, cfg: PlanConfig): PlanDay[] {19 const today = cfg.todayISO ? new Date(cfg.todayISO) : new Date();20 const exam = new Date(cfg.examDate + "T12:00:00");21 const days: string[] = [];22 for (let d = new Date(today); d < exam; d.setDate(d.getDate() + 1)) {23 if (cfg.weekdays.includes(d.getDay())) days.push(d.toISOString().slice(0, 10));24 }25 if (!days.length) return [];2627 const mastery = masteryForCourse(userId, courseCode).filter(28 (c) => (c.week ?? 0) >= cfg.weeksScope[0] && (c.week ?? 99) <= cfg.weeksScope[1]29 );30 if (!mastery.length) return [];3132 // Poids : importance × (1 − maîtrise), plancher pour que tout soit revu au moins une fois.33 const weighted = mastery.map((c) => ({ c, w: Math.max(0.15, c.importance * (1 - c.score)) }));34 const totalW = weighted.reduce((s, x) => s + x.w, 0);35 const totalMinutes = days.length * cfg.minutesPerSession;36 // Réserver ~20 % pour les examens blancs / révisions générales de fin de parcours.37 const conceptMinutes = totalMinutes * 0.8;3839 // File de blocs de 15-25 minutes par concept, à répartir en round-robin pondéré.40 type Block = { slug: string; name: string; kind: PlanItem["kind"]; minutes: number };41 const blocks: Block[] = [];42 for (const { c, w } of weighted) {43 const minutes = Math.max(20, Math.round((conceptMinutes * w) / totalW / 5) * 5);44 let remaining = minutes;45 let first = true;46 while (remaining > 0) {47 const m = Math.min(25, Math.max(15, remaining));48 blocks.push({49 slug: c.slug,50 name: c.name,51 kind: first ? (c.score < 0.3 ? "review" : "flashcards") : remaining <= 25 ? "quiz" : "flashcards",52 minutes: m,53 });54 remaining -= m;55 first = false;56 }57 }5859 // Entrelacement : trier par (semaine, passe) puis distribuer en serpentin sur les jours.60 const plan: PlanDay[] = days.map((date) => ({ date, items: [] }));61 let dayIdx = 0;62 const capacity = plan.map(() => cfg.minutesPerSession);63 for (const b of blocks) {64 // trouver le prochain jour avec de la place, en évitant 2 blocs consécutifs du même concept65 let attempts = 0;66 while (attempts < plan.length) {67 const i = dayIdx % plan.length;68 const last = plan[i].items.at(-1);69 if (capacity[i] >= b.minutes && (!last || last.conceptSlug !== b.slug)) {70 plan[i].items.push({71 kind: b.kind,72 conceptSlug: b.slug,73 label:74 b.kind === "review"75 ? `Revoir « ${b.name} » (explication + exemple)`76 : b.kind === "quiz"77 ? `Quiz ciblé : ${b.name}`78 : `Cartes mémoire : ${b.name}`,79 minutes: b.minutes,80 });81 capacity[i] -= b.minutes;82 dayIdx++;83 break;84 }85 dayIdx++;86 attempts++;87 }88 }8990 // Examens blancs : mi-parcours et avant-dernier jour disponible.91 const examBlock = (label: string): PlanItem => ({ kind: "exam", conceptSlug: null, label, minutes: Math.min(90, cfg.minutesPerSession) });92 if (plan.length >= 4) plan[Math.floor(plan.length / 2)].items.push(examBlock("Examen blanc de mi-parcours (mode pratique)"));93 if (plan.length >= 2) plan[plan.length - 1].items.push(examBlock("Examen blanc complet (mode chronométré)"));94 else plan[plan.length - 1].items.push(examBlock("Examen blanc (mode chronométré)"));9596 // Dernier jour : révision du cahier d'erreurs.97 plan[plan.length - 1].items.push({ kind: "review", conceptSlug: null, label: "Revoir le cahier d'erreurs (toutes les entrées « à revoir »)", minutes: 20 });9899 return plan.filter((d) => d.items.length);100}101