DocDoeAI / src /components /learn-anything /RoadmapPracticeSection.tsx
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
65d68f5
Raw History Blame Contribute Delete
11.7 kB
"use client";
/**
* The roadmap's practice loop: a progressive quiz (question first, reveal,
* honest self-mark, score at the end) and spaced-repetition flashcards
* (flip, then "knew it / again" drives a 1d/3d/7d Leitner schedule with a
* due-today queue). Replaces the old static lists that printed answers and
* card backs in plain sight. All state persists through the roadmap the
* same way topics do. Editorial ledger styling, no accent colour.
*/
import Link from "next/link";
import { useMemo, useState } from "react";
import {
dueFlashcards,
recordRoadmapQuizResult,
reviewFlashcard,
type LearnAnythingRoadmap,
type RoadmapQuizResult,
} from "@/lib/phase3-learning";
import { buildStudyIntentUrl } from "@/lib/study-intents";
export function RoadmapPracticeSection({
roadmap,
onChange,
onQuizSubmitted,
}: {
roadmap: LearnAnythingRoadmap;
onChange: (next: LearnAnythingRoadmap) => void;
onQuizSubmitted?: (result: RoadmapQuizResult) => void;
}) {
return (
<section className="mt-12">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] text-[#92949a]">Practice and revision</p>
<p className="mt-4 max-w-[62ch] text-sm font-light leading-relaxed text-[#62635e]">{roadmap.practiceTask}</p>
<FlashcardReview roadmap={roadmap} onChange={onChange} />
<ProgressiveQuiz roadmap={roadmap} onChange={onChange} onQuizSubmitted={onQuizSubmitted} />
</section>
);
}
function FlashcardReview({
roadmap,
onChange,
}: {
roadmap: LearnAnythingRoadmap;
onChange: (next: LearnAnythingRoadmap) => void;
}) {
const due = useMemo(() => dueFlashcards(roadmap), [roadmap]);
const [reviewing, setReviewing] = useState(false);
const [flipped, setFlipped] = useState(false);
const card = due[0];
const retired = roadmap.flashcards.filter((c) => roadmap.srState?.[c.front]?.retired).length;
function handleMark(remembered: boolean) {
if (!card) return;
setFlipped(false);
onChange(reviewFlashcard(roadmap, card.front, remembered));
}
return (
<div className="mt-8">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#a0a2a8]">Flashcards</p>
<p className="font-mono text-xs text-[#7a7c84]">
{due.length} due · {retired} retired
</p>
</div>
{!reviewing ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-[#e5e5e1] p-5">
<p className="text-sm font-light leading-relaxed text-[#62635e]">
{due.length > 0
? `${due.length} card${due.length === 1 ? "" : "s"} due for review. Remembered cards come back in 1, 3, then 7 days.`
: "All caught up — nothing due right now. Cards return on their schedule."}
</p>
{due.length > 0 ? (
<button
type="button"
onClick={() => setReviewing(true)}
className="inline-flex h-10 items-center rounded-lg bg-white px-5 text-sm font-bold text-black transition hover:bg-[#303136]"
>
Review now
</button>
) : null}
</div>
) : card ? (
<div className="mt-3 rounded-2xl border border-[#e5e5e1] bg-white p-6 shadow-[0_1px_2px_rgba(15,17,23,0.03)]">
<p className="font-mono text-[11px] text-[#a0a2a8]">
Card · {due.length} left
</p>
<p className="mt-4 text-lg font-semibold leading-snug text-[#17181b]">{card.front}</p>
{flipped ? (
<>
<p className="mt-4 border-t border-[#e5e5e1] pt-4 text-sm font-light leading-relaxed text-[#45474d]">
{card.back}
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => handleMark(true)}
className="inline-flex h-10 items-center rounded-lg bg-white px-5 font-mono text-sm font-bold text-black transition hover:bg-[#303136]"
>
Knew it
</button>
<button
type="button"
onClick={() => handleMark(false)}
className="inline-flex h-10 items-center rounded-lg border border-[#deded9] px-5 font-mono text-sm font-semibold text-[#45474d] transition hover:border-white/40 hover:text-[#17181b]"
>
Again
</button>
</div>
</>
) : (
<button
type="button"
onClick={() => setFlipped(true)}
className="mt-6 inline-flex h-10 items-center rounded-lg border border-[#deded9] px-5 text-sm font-semibold text-[#45474d] transition hover:border-white/40 hover:text-[#17181b]"
>
Show answer
</button>
)}
</div>
) : (
<div className="mt-3 rounded-2xl border border-[#e5e5e1] p-5">
<p className="text-sm font-light text-[#62635e]">
Session done — everything due is reviewed. Come back tomorrow.
</p>
<button
type="button"
onClick={() => setReviewing(false)}
className="mt-3 text-sm font-semibold text-[#7a7c84] transition hover:text-[#17181b]"
>
Close
</button>
</div>
)}
</div>
);
}
function ProgressiveQuiz({
roadmap,
onChange,
onQuizSubmitted,
}: {
roadmap: LearnAnythingRoadmap;
onChange: (next: LearnAnythingRoadmap) => void;
onQuizSubmitted?: (result: RoadmapQuizResult) => void;
}) {
const questions = roadmap.quiz;
const [state, setState] = useState<"idle" | "running" | "done">("idle");
const [index, setIndex] = useState(0);
const [revealed, setRevealed] = useState(false);
const [gotIt, setGotIt] = useState<boolean[]>([]);
const last = roadmap.quizResult;
function start() {
setState("running");
setIndex(0);
setRevealed(false);
setGotIt([]);
}
function mark(correct: boolean) {
const marks = [...gotIt, correct];
if (index + 1 < questions.length) {
setGotIt(marks);
setIndex(index + 1);
setRevealed(false);
return;
}
const missed: string[] = [];
questions.forEach((q, i) => {
if (!marks[i]) missed.push(q.question);
});
const next = recordRoadmapQuizResult(roadmap, {
correct: marks.filter(Boolean).length,
total: questions.length,
missedQuestions: missed,
});
onChange(next);
if (next.quizResult) onQuizSubmitted?.(next.quizResult);
setGotIt(marks);
setState("done");
}
const question = questions[index];
const correctCount = gotIt.filter(Boolean).length;
const missed: string[] = [];
questions.forEach((q, i) => {
if (!gotIt[i]) missed.push(q.question);
});
return (
<div className="mt-8">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<p className="text-[11px] font-semibold uppercase tracking-wide text-[#a0a2a8]">Quiz</p>
{last ? (
<p className="font-mono text-xs text-[#7a7c84]">
last run {last.correct}/{last.total}
</p>
) : null}
</div>
{state === "idle" ? (
<div className="mt-3 flex flex-wrap items-center justify-between gap-4 rounded-2xl border border-[#e5e5e1] p-5">
<p className="text-sm font-light leading-relaxed text-[#62635e]">
{questions.length} questions. See each answer only after committing, then mark yourself honestly.
</p>
<button
type="button"
onClick={start}
className="inline-flex h-10 items-center rounded-lg bg-white px-5 text-sm font-bold text-black transition hover:bg-[#303136]"
>
{last ? "Retake quiz" : "Start quiz"}
</button>
</div>
) : null}
{state === "running" && question ? (
<div className="mt-3 rounded-2xl border border-[#e5e5e1] bg-white p-6 shadow-[0_1px_2px_rgba(15,17,23,0.03)]">
<p className="font-mono text-[11px] text-[#a0a2a8]">
Q {String(index + 1).padStart(2, "0")} / {String(questions.length).padStart(2, "0")}
</p>
<p className="mt-4 text-lg font-semibold leading-snug text-[#17181b]">{question.question}</p>
{revealed ? (
<>
<p className="mt-4 border-t border-[#e5e5e1] pt-4 text-sm font-light leading-relaxed text-[#45474d]">
{question.answer}
</p>
<p className="mt-5 text-[11px] font-semibold uppercase tracking-[0.18em] text-[#92949a]">Did you have it?</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => mark(true)}
className="inline-flex h-10 items-center rounded-lg bg-white px-5 font-mono text-sm font-bold text-black transition hover:bg-[#303136]"
>
Got it
</button>
<button
type="button"
onClick={() => mark(false)}
className="inline-flex h-10 items-center rounded-lg border border-[#deded9] px-5 font-mono text-sm font-semibold text-[#45474d] transition hover:border-white/40 hover:text-[#17181b]"
>
Missed it
</button>
</div>
</>
) : (
<button
type="button"
onClick={() => setRevealed(true)}
className="mt-6 inline-flex h-10 items-center rounded-lg border border-[#deded9] px-5 text-sm font-semibold text-[#45474d] transition hover:border-white/40 hover:text-[#17181b]"
>
Show answer
</button>
)}
</div>
) : null}
{state === "done" ? (
<div className="mt-3 rounded-2xl border border-[#e5e5e1] p-6">
<p className="font-mono text-2xl font-semibold text-[#17181b]">
{correctCount}/{questions.length}
</p>
<p className="mt-1 text-sm font-light text-[#6a6b72]">
{missed.length === 0 ? "Clean run — everything held." : "Missed questions to close out:"}
</p>
{missed.length > 0 ? (
<>
<ul className="mt-3">
{missed.map((q) => (
<li key={q} className="border-b border-[#efefec] py-2 text-sm text-[#45474d]">
{q}
</li>
))}
</ul>
<Link
href={buildStudyIntentUrl({
intent: "generate_quiz",
prompt: `I missed these questions on ${roadmap.topic}: ${missed.join(" | ")}. Reteach the ideas briefly, then quiz me again on them.`,
run: true,
})}
className="mt-4 inline-block text-xs font-semibold text-[#17181b]/65 underline decoration-white/25 underline-offset-4 transition hover:text-[#17181b]"
>
Practice the missed ones in Study Chat
</Link>
</>
) : null}
<div className="mt-4">
<button
type="button"
onClick={start}
className="text-sm font-semibold text-[#7a7c84] transition hover:text-[#17181b]"
>
Retake
</button>
</div>
</div>
) : null}
</div>
);
}